- VulkanRenderer::LookupVaoDrawMemo's legacy arm read MG_Config::Features.PipeHandleAbaControl
a fourth time, bypassing the one-question accessor MagmaPipeAbaControlDefeatsIdentity that
55d2af9b's own message says all the control's sites go through. It could never be false
there: the control's early return fires ahead of BOTH arms, so a run that reaches the legacy
compare has the knob off. The compare is unconditional again, with a note saying where the
question is answered and to route through the accessor if that early return is ever narrowed
- which is the trap the dead line was, since narrowing it would have silently restored D18's
retired semantics at this one site.
- HandleRecycleScenario's vertex-array case still said "BOTH the VAO and the buffer are
recycled here so that a key built out of raw addresses matches", which the body has
contradicted since 55d2af9b: both buffers are created before the ABA window and neither is
deleted inside it, deliberately, so that no buffer traffic moves VkBufferManager's
slice-epoch counter inside the window. Only the VAO is recycled, and only its GL name - the
heap block is not handed back, which is why the knob rather than the allocator constructs the
AbaControl arms' collision.
- 55d2af9b claimed - in its message, in MagmaPipeArms.h, in VertexInputStateFactory.cpp and
in MG_IntegrationTest/CMakeLists.txt - that the AbaControlHandles lane defeats the
GENERATION in {slot, gen}. It does not, and no lane of that shape can. Magma's mint has no
death notification (nothing in MG_Backend/DirectVulkan consumes NotifyStateObjectDestroyed)
and returns a slot only through OnFrameBoundary's age sweep, kSweepInterval 256 /
kRetireAgeBoundaries 1024; HandleRecycleScenario issues five frame boundaries, so the
replacement VAO acquires against an empty free list and gets a BRAND-NEW slot at Gen 1
(measured: redVao slot=2 gen=1, greenVao slot=3 gen=1). The knob-off FRESH verdict there is
decided by the SLOT alone, and deleting ++m_entries[index].Gen leaves all 32 HandleRecycle
entries green - re-measured this round.
- What the lane does defeat is the object identity that SELECTS the slot, which IS the key the
handle arm ships, and that is what the three code sites now say. The two requirements are
mutually exclusive for the pixel-visible memo: a genuine slot reuse needs >= 1024 idle
boundaries after the dead object's last draw, which necessarily puts the two draws in
different frames, and ResolvedVertexBindings - the only memo carrying a GPU slice rather
than a layout - declines across frames by design.
- So the generation is covered where it IS expressible. MG_Test/Pipe/MagmaPipeIdentityTest.cpp
drives the mint's real retire -> reuse (1280 boundaries, with a keep-alive object holding the
first allocatable slot so the reuse is not the slot the control aliases onto) and asserts
four things: the retired slot comes back with Gen+1; with the knob OFF a memo stamped at
{slot, gen=N} is NOT served at {slot, gen=N+1}; with the knob ON it IS, out of one uncleared
and unclaimed entry; and a live object keeps its slot, its generation and its memo across two
sweeps, so the generation cannot be "fixed" by bumping it on every acquisition.
- The claim rule itself moves into MagmaPipeArms.h as MagmaPipeClaimSlotMemos so the suite
exercises production code rather than a copy of it. VertexInputStateFactory::MemosFor is now
one call to it and is otherwise unchanged, on both the knob-on and the knob-off path.
- Load-bearing, measured: with ++m_entries[index].Gen commented out, ctest -L unit in
build-push goes 1563/1566 - three of the four new cases red, one of them naming the inherited
0xDEAD payload out of the same slot - while ctest -R HandleRecycle stays 32/32. Restored, all
four pass in build-push and build-verify and skip visibly in the pull build, so the ctest name
sets stay identical (G2).
- an ENVIRONMENT property overrides only the variables it names, so the gate's all-pull control
arm (MOBILEGL_PIPE_PUSH=0 over the whole integration-gpu label) leaked into the Handles and
AbaControlHandles lanes, turned their LEGACY_MEMOS=0 into D14's armless combination and made
the bring-up abort by design; the lanes now pin the P2 default bitmask themselves
- MOBILEGL_PIPE_HANDLE_ABA_CONTROL asserted the corruption and saw correct pixels, so
DirectVulkan.HandleRecycle.AbaControl.*AVertexArray* was RED in an always-on
integration-gpu lane while every guard it was supposed to be defeating stood. Two
measured reasons, neither of them the {slot, gen} re-key: (1) D18 spelled the control as
"hash the raw BufferObject* instead of its lifetime id, and skip the vaoLifetimeId
compare", which only collides if the allocator hands the freed block back - it does not.
glGen* recycles the NAME, but a VertexArrayObject is 3920 bytes, past glibc's tcache, so
its chunk goes to the unsorted bin and is split by the next allocation the replacement
path makes; four create/delete cycles in one run gave four addresses ~1 MiB apart, and
the BufferObject behaves the same. (2) The reproducer put a frame boundary between the
arming draw and the recycled draw, and the only memo that carries a GPU slice rather
than a layout - ResolvedVertexBindings - declines across frames by design, so no key
collision whatsoever could have shown up in pixels.
- The control no longer asks the allocator for the collision: on both arms it replaces the
object identity in DirectVulkan's vertex-input keys with a constant, which is the
strongest form of "the block came back" and is deterministic. Three sites, all behind
one question (MagmaPipeAbaControlDefeatsIdentity): the buffer identity leaves
VertexInputStateFactory::ComputeHash, VertexInputStateFactory::MemosFor claims one entry
without its Owner compare, and VulkanRenderer::LookupVaoDrawMemo hands one entry back
uncleared ahead of both arms.
- That is what makes the control cover the key P2 SHIPS. Under MOBILEGL_PIPE_PUSH=0 the
handle arm is not executed at all, so the old control said nothing about the generation
in {slot, gen} - the whole of what makes the re-keyed memos ABA-safe. A second lane,
DirectVulkan.HandleRecycle.AbaControlHandles., runs the handle arm with the knob and
asserts the same corruption; D18's lane is kept verbatim beside it for the pre-handle arm.
- The reproducer's two draws now share a frame, and both buffers are realised before the
window, so a moved slice epoch cannot mask the ABA behind a gate that is not about
identity. Nothing else is relaxed: the frame serial, the slice epochs and the host-map
check stay in force, so a green arm still means "a replacement object was handed its
predecessor's resolved vertex bindings because the identity halves of the keys were
defeated".
- ExpectPixelsFor now prints what it OBSERVED (STALE/FRESH/NEITHER) next to what the arm
expected, on every arm and whether or not the case passes.
- Knob-off is unchanged and the pull build is untouched: every new branch is
#if MOBILEGL_PIPE_PUSH, and symbol_report.py --threshold 0 against the pre-P2 baseline
still reports 0 added / 0 removed / 0 renamed and the same four resized symbols
(RenderState::RenderState, SetCapability, IsCapabilityEnabled, _GLOBAL__sub_I_DirectGLES.cpp).
- bench.sh's pin_freqs writes /proc/ppm and /proc/gpufreq, which exist on neither 35d0befa
(SM8750) nor 3B159D009VZ00000 (MT6993 dropped both for /proc/gpufreqv2); the guard the
PROFILE_VERIFIED key exists for was about to be defeated on the device its own comment
guessed was safe
- pin_device.sh pins big/little/GPU through the nodes each device actually has (Xiaomi:
policy6 1958400 / policy0 1555200 / kgsl pwrlevel 0; Oppo: policy4 2000000 + policy7
2000000 / policy0 1600000 / gpufreqv2 fix_target_opp_index 0), checks only against its own
pins because ColorOS moves stock maxima by itself, and refuses to call a zero-key read a verdict
- both profiles are PROFILE_VERIFIED=1 on 30 s pinned windows with zero drift; the evidence and
the two things not verified (Oppo GPU held at DVFS-request level only, no full bench window)
are in docs/Disaggregated/devices/pin-verification-2026-09-07.md
- -Pmobilegl.pipePush=ON (or MOBILEGL_PIPE_PUSH_APK=ON) passes -DMOBILEGL_PIPE_PUSH=ON to
the NDK build the way the spike opt-in already does; OFF by default so nothing shipped moves
- spans (d1a7c5f1) removed GetPixelStoreParameters from the emitted list because the call
carries the pack half of a two-element field, so the generated MGPipeFieldEmitter no longer
has a SetPixelPackState enumerator; the tracker's subsystem map and its static_assert, written
against the contract, still named it and the push and verify builds did not compile on the
integrated tree
- the NEW_PIXEL_PACK dirty bit still names the pixel-pack subsystem, which is what the
emission gate consults; the field itself stays in the residual fill loop by design
- bench.sh and session.sh are invoked as ./tools/device_bench/bench.sh in their own README
and are the harness for the paired two-device A/B the GO/NO-GO asks for; a previous commit
in this package dropped the bit and every check invoked them through bash, so nothing saw it
- The verified-profile guard read the process environment as well as the profile: the test ran
after the source, so PROFILE_VERIFIED=1 exported in an operator's shell re-opened the fail-open
hole for every profile that says nothing. Both scripts now set PROFILE_VERIFIED=0 immediately
before sourcing, so the file is the only thing that can answer.
- A --device path that cannot be sourced was diagnosed as an unverified profile, because both
scripts cd to their own directory first and neither checked readability. The path is now also
tried relative to the directory the script was invoked from (which is what a repo-root-relative
--device means), and an unreadable one is reported as unreadable, naming both places tried.
- Verified: exported PROFILE_VERIFIED=1 + an unverified profile -> rc 2; exported 1 + a profile
with no key -> rc 2; a repo-root-relative path -> resolved, then refused for its own reason;
a missing file -> "cannot read the device profile"; odinlite.env -> past the guard;
--allow-unverified-profile -> the three warnings, then proceeds.
- test.yml's new step described a mechanism the tree does not have. G6's and G10's entries are
registered in the pull build too - they must be, for G2's name-for-name comparison - and skip
inside their bodies. The step's value is unchanged and its comment now says the true thing: the
`test` job runs those names as a column of skips, and this is the first CI job that unpacks a
build which compiled the assertions.
- trace_benchmark takes the wall baseline before the CPU baseline, the order OnFrameBoundary
already reads them in, so frame 0 stops reporting a CPU delta biased upward against its own
wall delta; and it includes <time.h> rather than <ctime> for the POSIX names it uses.
- All four capability markers are now content probes over the directory the owning package owns,
through one helper. The magma pair still read a single hard-coded VertexInputStateFactory.cpp
while package D already keeps one of its two Features.PipeHandleAbaControl consumers in
Renderer/VulkanRenderer.cpp, so one file move on D's side was a permanent AbaControl skip - the
same defect the CSO probe was rewritten for. The DirectGLES probe stops asking whether
SlotTables.h exists and asks for kMGPipeSubsystemEsprytSlots, the bit the arm is actually gated
on. Every globbed file stays in CMAKE_CONFIGURE_DEPENDS, and the glob is CONFIGURE_DEPENDS.
- Verified: with one throwaway header naming each symbol, build-push configures to "keyed on
{slot, gen}" / "has an emitter" / "has a consumer" and all four MGITEST_* markers appear 24
times in the generated ctest environments; with the headers gone, all four are back to 0 and
the four "will SKIP" verdicts return. The magma sim sat in Renderer/, not in the path the old
probe hard-coded.
- g7_negative_control.sh no longer exits 0 when the control trips for the wrong reason. A
SetterConsistency that had gone red for an unrelated reason satisfied "ctest failed" and never
named SetColorMask, and the integrator's D.3 reads this script's rc. The verdict is now taken
after the restore and the rebuild - a broken build directory is worse than any exit status -
and reported as rc 1 with the output kept, alongside the existing "did not trip" rc 1.
- HandleRecycleScenario writes down what the name-recycle proxy costs: the AbaControl arm asserts
corruption that needs the heap BLOCK back, sees only the NAME, and so can red an always-on
integration-gpu lane for an allocator reason. That trade is deliberate - the alternative is an
arm that is green on the day the reproducer stops reproducing - and the consequence is now
written both in the header and at the skip that is the last thing standing between the two.
- DriverBenchStateToggle was an entry that could not fail for the reason it was added. A case
name matching nothing in kBenchCases selected nothing, run_case is void, and main returned 0
unconditionally, so renaming or dropping mc_state_toggle left the entry green while measuring
nothing - the exact state it was landed to end (ROADMAP.md:7).
- DriverBench now refuses an unknown case name before any GL work (exit 2, listing the cases it
does have), so a caller that names a case - run_driver_bench.sh included - learns the case is
gone instead of getting an empty CSV.
- Both ctest entries additionally require the case's own output row via PASS_REGULAR_EXPRESSION,
so the gate stands on the evidence rather than on that check staying in the binary. The
toggle entry pins the ops-per-frame column to 46, because the mc_* cases are deliberately
excluded from the DRIVERBENCH_DRAWS scaling and 46 toggles per frame is part of what "this
case still runs" means. A PASS_REGULAR_EXPRESSION makes ctest ignore the exit code, which is
why the row is what is checked; the comment says so.
- Verified: renaming mc_state_toggle in kBenchCases -> DriverBenchStateToggle FAILS; setting its
ops-per-frame to 45 -> FAILS; restored -> both entries pass again.
- format_benchmark printed a p50 taken with the nearest-rank rule beside a medianFrameCpuMs the
device computes as the average of the two middle frames, and documented the two as one rule; on
an even window they differ (the pre-flight printed p50=8.261ms next to medianCpuMs=271.766).
p50 now goes through series_median, which is SummarizeSeries' rule transcribed; p95 and p99 stay
nearest rank, which is the device's rule for p95 and the honest extension of it for the p99 the
device does not compute at all
- require_verified_profile treated a profile that simply omits PROFILE_VERIFIED as verified, which
is the fail-open default a profile written by copying another one inherits - exactly the case the
guard exists for. It defaults to unverified now, odinlite.env carries PROFILE_VERIFIED=1
explicitly (it is the one profile that earned it), and the refusal says "says 0, or says nothing"
- the two new profiles claimed profile.sh refuses an unverified profile; it has no such check and
needs none - it records a simpleperf profile and pins nothing. The claim is corrected in both
profiles and in the README rather than a guard added where there is nothing to guard
- the handle-ABA / CSO control step in test.yml set only MOBILEGL_ITEST_REQUIRE_GPU while its
sibling verify step sets the three MOBILEGL_MAGMA_* fixes and arms core dumps. It runs the same
DirectVulkan binary on the same runner, so a crash there left no core; it now carries both
- the three capability markers were decided from source-tree file existence / file text alone, so
after packages C and D land they would have armed the PULL build too, where every arm they name
is compiled out: the AbaControl lane would have gone hard red on `ctest -L integration-gpu`
(gate G2 requires it green in both builds) and the Handles lane green against a library with no
{slot, gen} key at all. The whole block now sits under the same `if (MOBILEGL_PIPE_PUSH)` as
MGITEST_PIPE_PUSH_BUILD, and HandleRecycleScenario re-checks that marker before either push arm
asserts, so a hand-forced environment cannot arm an arm this build does not have either
- the two push-only knobs of those lanes (MOBILEGL_PIPE_LEGACY_MEMOS=0, MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1)
are set only in a push build. In a pull build the legacy arm is the only arm and every subsystem
bit is clear, which is D14's startup Fatal{PipeLegacyMemosDisabled} - the process would abort
before the scenario could report its skip. Test NAMES are unaffected, so G2 still compares equal
- the CSO control armed itself off `MG_Impl/Pipe/Tracker.cpp`, a file the owning package does not
create: it implements the tracker and the cache header-only, so all four CsoContentAddressing
entries would have kept skipping after it landed, with a reason that had become false. The probe
now greps every source under MG_Impl/Pipe/ for the two counters the control actually reads
(RenderStateCsoMints / RenderStateCsoBinds), watching the directory and each file, so the owning
package keeps control of its file layout
- an unrecognised MGITEST_HANDLE_ARM is a FAIL in SetUp instead of a silent downgrade to the
Legacy arm, which would have passed while claiming to be the lane it was not
- CsoContentAddressingScenario reads the library's own summary line, and a log is a per-LANE
resource: the library opens it fopen(path, "w"), so every process in a lane truncates it. The
file had TWO cases in each lane, which under `ctest -j` is a race whose failure mode is an
empty read - indistinguishable from "the counters were never emitted", which is precisely the
thing the case exists to report on.
- The separate plumbing case is folded into the control as its first ASSERT, keeping its own
message, so nothing is lost but the flake. Splitting it out bought a clearer failure message
and paid for it with a flake in the mechanism that message is about.
- This is the same hazard the file's existing comments describe for the arming lane; it is worth
saying out loud that the rule is "a log-reading case owns its lane", not "a log-reading case
owns its log path".
- Verified at -j 4: 44/44 on build-verify and 24/24 on build-push, and the pull/push ctest name
lists are still identical (1402 entries each; 0 names removed against the contract tree, 34
added).
- pipe-gates stops printing gen_pipe_dirty_surface.py --summary and runs --check && --self-test.
--check fails both directions - a scanned mutator with no row in MG_Pipe/DirtySurface.def, and
a row naming a mutator the scan no longer finds - so a deleted mutator cannot leave a stale row
behind claiming coverage. --self-test is what keeps --check honest: a completeness check that
silently stopped checking is indistinguishable from a complete mapping, so two canned negative
controls must both trip. Same shape as gen_pipe.py --self-test next to it.
- integration-verify gains `ctest -L unit`. G6's chunk-table walk and G10's residual assertions
live in MG_Test/Pipe, compiled only under MOBILEGL_PIPE_PUSH, and the `test` job builds the
PULL library - so before this those tests ran in no CI job at all. The artifact already carries
them (the packaging step tars MobileGL/MG_Test whole), so the whole cost is the run: ~14 s for
~1490 entries, measured locally on this tree.
- integration-verify also runs the two always-on negative controls by name. They are labelled
integration-gpu rather than integration-verify - they are about the handle key and the CSO
switch, not the comparator - and this is the only CI job that unpacks a push build, which
CsoContentAddressingScenario needs because both counters and the cso[] bracket are
#if MOBILEGL_PIPE_PUSH.
- build-linux-verify's arming check accepts the per-verb entry point under either of its two
names. P2 renames MGPipeFillForVerb to MGPipeValidateForVerb, and a check that named only the
old one would go red on the rename for a reason unrelated to what it tests. What it tests is
unchanged: the artifact has a per-verb entry point, and it still fails when there is none.
- G2 requires `ctest -L integration-gpu` to be name-for-name IDENTICAL between the pull build and
the push build, and the four CsoContentAddressing lanes were registered inside
`if (MOBILEGL_PIPE_PUSH)`. That is four entries the push build has and the pull build does not,
which breaks the comparison for this package and for every package that lands after it.
- They now register unconditionally. What the pull build lacks is not the entry but the thing the
entry is about, so the build passes MGITEST_PIPE_PUSH_BUILD in and the scenario skips saying
exactly that: no render-state CSO exists, no cso[] bracket is compiled into the summary line,
and the content-addressing bit steers nothing.
- The marker also sharpens the plumbing assertion it guards. Past that skip the process is known
to be a push build, and the cso[] bracket is unconditional inside that same #if - so a missing
bracket can no longer mean "wrong build configuration" and the failure message stops offering
that as an explanation.
- Verified: build-linux and build-push now differ by zero ctest names (diff empty over 1408
entries each), and the pull lanes skip with the push-build reason while the push lanes skip
with the tracker-not-landed reason.
- DriverBenchStateToggle runs mc_state_toggle as its own ctest entry. The case has been in
kBenchCases since P0 and nothing executed it, so nothing would have noticed it rotting - and it
is the exact enable/draw/disable/draw shape the microbenchmark P2 owes the GO/NO-GO measures.
About 1.2 s inside an existing three-minute job.
- scripts/g7_negative_control.sh breaks the pipeline/dynamic split on purpose: it inserts two
boundaries so ColorMasks becomes a dynamic chunk of its own, which keeps the partition sorted,
non-overlapping and complete - so it still COMPILES - while making glColorMask bump
m_pipelineStateVersion without moving the pipeline-subset hash. A non-zero ctest is the pass.
- Everything that could make that control lie is refused rather than reported: a missing
SetterConsistency test exits 2 instead of reading "no tests matched" as a failure; a tree that
is already red or already broken exits 2; a patched table that does not compile exits 2, since
a build break would prove the static_asserts work rather than that the test still checks; and
the restore is from byte-for-byte copies (never from git, so a dirty tree is given back
intact), followed by a rebuild and a re-run that must be green. --verify-patch-only exercises
the mechanism where the test does not exist yet and says explicitly that it is not a pass.
- Profiles for the two campaign devices, and the guard that stops them being trusted early. Both
carry PROFILE_VERIFIED=0 and every device-specific field is TODO_VERIFY_ON_DEVICE rather than a
guess: the harness pins through MediaTek nodes and 35d0befa is a Qualcomm part, where
`su -c 'echo ... > /proc/ppm/...'` fails with a zero exit and the run would report numbers it
believes were pinned. bench.sh and session.sh now refuse an unverified profile unless
--allow-unverified-profile is passed, which warns that the run is not comparable with a pinned
one. The README records what earns PROFILE_VERIFIED=1.
- The metric the disaggregation GO/NO-GO hangs on is per-thread CPU p50/p99, and the tree had no
first-party collector for it: no CLOCK_THREAD_CPUTIME_ID, no getrusage, no RUSAGE_THREAD, no
/proc/self/task anywhere under MobileGL/, tools/, android-plugin/ or scripts/.
- The retrace loop is the cheapest honest place to take it. Retrace runs --singlethread and
trace_benchmark states that Begin/OnFrameBoundary/End are only ever reached from that one
thread, so that thread's CPU time IS the client-side CPU cost: one extra clock_gettime per
frame, no root, no profiler, no debuggable build, no sampling - and no instrumentation
committed to a hot path inside the library.
- The CPU reading is taken before the wall reading at each boundary, so the syscall lands in the
wall delta rather than hiding inside the CPU delta. An inflated wall number is visible; a
deflated CPU number would not be.
- Report::frameCpuMs is the same length as frameMs or it is EMPTY. A clock that started failing
mid-run would otherwise be silently re-indexed and put frame N next to frame N+k, and an empty
series and a series of zeroes are different claims about the platform.
- SummarizeBenchmark is split into SummarizeSeries and reused verbatim for the CPU series rather
than duplicated: same tail window, same even-count median rule, same nearest-rank p95, or the
delta between the two series stops meaning anything.
- benchmark.json gains meanFrameCpuMs / medianFrameCpuMs / p95FrameCpuMs and the WHOLE
frameCpuTimesMs[] array beside frameTimesMs[]; result.json and the completion line gain the
three headline numbers. p99 therefore needs no device change - it is a host-side reduction over
an artefact that already exists, and run_android_retrace_local.py prints p50/p95/p99 off the
same trailing window the device summarised.
- Pre-flighted on the desktop CLI, which shares the same core: 2-frame run, cpu series aligned
with the wall series and strictly below it (wall 1290.787/20.225 ms, cpu 535.271/8.261 ms).
- HandleRecycleScenario builds the ABA the Track H re-key has to survive: an object is drawn
for three frames so every per-object memo is armed against it, unbound so its last SharedPtr
drops, deleted, and replaced immediately by one with a byte-identical configuration and
different contents. Three kinds - a vertex array whose buffer is recycled with it, a texture,
a framebuffer - and the readback must come from the replacement.
- The reproducer is asserted, not assumed. TheReproducerRecyclesEveryName pins that the name
allocators hand every deleted name straight back, and a case whose names were not recycled
SKIPS as "inconclusive, not proven" rather than passing - the shape ObjectLifetimeIdTest
already uses.
- Three always-on arms, one ctest lane each, named by the harness marker MGITEST_HANDLE_ARM:
Handles ({slot, gen} only), Legacy (today's lifetimeId + weak_ptr guards) and AbaControl
(MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1, which expects the CORRUPTION so that a reproducer that
stopped reproducing is a red rather than a quieter green). AbaControl is DirectVulkan only:
the knob reverts two DirectVulkan guards and steers nothing on DirectGLES.
- CsoContentAddressingScenario is the G12 control. A Blaze3D blend toggle - enable/draw/
disable/draw x 8 inside one frame - must mint a BOUNDED number of CSOs with content
addressing on and exactly one per bind with bit 63 of MOBILEGL_PIPE_PUSH set, while the
pixels do not move at all. csom == csob is the reading a dead switch cannot produce.
- The counters are read from the library's own "MGPipe stats:" line, because PipeStats is
internal and this module links the shipping library on Android. Each arm therefore gets
MOBILEGL_PIPE_STATS_PERIOD=1 and a private MOBILEGL_LOG_FILE_PATH, the same per-lane rule
the arming lane already follows, and the workload is bracketed by two swaps so the window
covers itself and nothing else.
- Both scenarios skip in the ambient entries, which configure none of the knobs their arms are
about, and both name what is missing when the package they depend on has not landed. What
decides that is the BUILD, not a hand-written guard: CMakeLists looks for
DirectGLES/SlotTables.h, MG_Impl/Pipe/Tracker.cpp and the two markers inside
VertexInputStateFactory.cpp, prints each verdict, and re-evaluates through CONFIGURE_DEPENDS -
so the arms arm themselves when packages B, C and D land.
- The live-object high-water mark is the number review v2's MAJOR 1 wants measured on
minecraft-1.21.4-in-world and ...-sodium-in-world, and no desktop gate can produce it. It
was emitted at MGLOG_D, which is compiled out of every build that ships and of every build
P2 measures, so the line existed only in a configuration nobody runs.
- MGLOG_I instead, still only on the allocate-a-new-slot branch and still only at powers of
two from 1024 up: at most a handful of lines for a whole session, never one on a draw
(ROADMAP.md:7). Declared as a narrow deviation from D20's "MGLOG_D for anything
non-critical" in the comment beside it.
- MagmaPipeIdentityTable was a FIXED 2048/8192-entry, 2-way set-associative LRU. Above
capacity it evicted LIVE objects, and every memo keyed on the handle died with them: a
verbatim transcription of the previous Acquire lost 54% of uses' handles at 2500 live VAOs
against 2048 entries, and 20% at 1024 live VAOs once the lifetime ids are sparse (an app
that creates and destroys VAOs - the Minecraft chunk shape this exists for).
- Two of the three memos it fed had NO capacity before this package: the content-hash memo
and the resolved-state memo were unbounded mutable fields on VertexArrayObject. Eviction
there turns one ComputeHash per VAO reconfiguration into one per DRAW; once the buffer
table thrashes too, the vertex-input content hash becomes a per-draw value that inserts a
fresh heap-allocated BackendVertexInputState into an unbounded map on every draw, swept
only every 256 frame boundaries. That is a worse leak than the one the fixed table was
introduced to avoid.
- So the mint grows on demand and reclaims by AGE: a lifetime-id map with a one-entry front
memo, a free list, and an OnFrameBoundary sweep on the same cadence and retirement age as
the cache entries those slots key. Footprint tracks the live DRAWN working set instead of
objects ever created, which is the property MG_Impl/Pipe/SlotAllocator cannot have here
(nothing in P2 can call its Free). Re-run of the same workloads: handle churn is 0.0% at
512, 1024, 2048, 2500, 3000, 4000, 8192, 10000 and 16000 live objects, consecutive and
sparse ids alike, at 1 and 5 acquisitions per use.
- VertexInputStateFactory::m_vaoMemos follows the mint with no capacity of its own, through a
chunked table whose entry addresses never move - which is what the fixed table's only real
guarantee was, and D12.4's grow-on-demand ask without a relocating Vector.
- VulkanRenderer::m_vaoDrawMemoTable deliberately keeps the base ref's 2048 entries and the
base ref's older-frameSerial victim rule, and changes only its KEY. It is the one memo of
the three that had a capacity before P2, a VaoDrawMemo is ~450 B, and losing one costs one
vertex-binding re-resolve. Measured steady-state miss rate against the base ref's
address-hashed table: 0.0% vs 6.4% at 512 live VAOs, 0.0% vs 24.0% at 1024, 0.0% vs 60.0%
at 2048, 36.2% vs 69.5% at 2500, 63.5% vs 79.1% at 3000; both are ~100% at 4096 (2x
capacity), where an LRU on a cyclic pattern cannot win.
- The two tables are now a MagmaPipeIdentityTables member of VulkanRenderer, handed to its
VertexInputStateFactory, instead of two function-local statics that outlived every context
and shared one reclamation clock across two.
- A Gen that reaches 2^32-1 retires its slot for good rather than wrapping. MOBILEGL_ASSERT
is compiled out of every build P2 runs, and a DEBUG-level build of this tree does not
compile at all (MG_Util/Types.h uses MOBILEGL_ASSERT before MGLOG_F is declared - untouched
since the base ref, and not this package's file), so the defence has to be on the release
path to exist.
- The no-CSO pipeline-memo fallback stops using MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL is
an unconditional std::atomic_flag::test_and_set - a locked xchg per evaluation, not "one
static bool test" - and this site is on the per-draw path in exactly the configuration that
reaches it. A plain per-renderer bool replaces it, and the comment now says what the
warning's absence does and does not prove (nothing at all while bit 0 is clear).
- MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState clear still runs the
pre-handle state hash - there is a correct answer there and bit 0 is not Track H, so it is
not fatal - but it is no longer silent: the startup gate names the combination.
- The D12.3 static_assert block now names D19's DynamicChunksCoverMagmasDynamicTailKey, whose
ctest entry lives in package A's file, so the integrator can see which half is missing.
- MagmaPipeSlotIndex subtracted kMGPipeFirstAllocatableSlot unconditionally, so a null
handle would have indexed a per-slot table at ~0u. It is unreachable - both lifetime-id
sources start at 1, which is why Acquire's zero guard never fires - but the consequence
of being wrong about that is an out-of-range write, not a wrong answer, so it is asserted
and the index is pinned to 0 in a release build.
- MOBILEGL_PIPE_LEGACY_MEMOS=0 no longer aborts a draw. D14 spends that lever at
STARTUP and only on a Track-H subsystem, so MagmaPipeValidateSubsystemConfiguration
runs once from VulkanRenderer::Initialize and checks bit 6 alone: an Espryt-side
bitmask cannot kill a Magma run, and bit 0 - which is not Track H and not a memo
re-key - is out of the lever's scope entirely. MOBILEGL_PIPE_PUSH=0x60 with the
lever off went from 9/9 aborted to 432/432.
- A pipeline memo with no render-state CSO bound falls back instead of aborting.
delete_render_state clears the binding, so the null handle is reachable on any
tree; the fallback is the pre-handle state hash where one is compiled, and the
client's own MGPipeComputePipelineSubsetHash over the same 396 pipeline bytes
where it is not - which is what makes -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF a runnable
configuration (180/432 aborted before, 432/432 now) instead of a build that dies
on its first draw. The fallback warns once, so a run that never keys on a CSO
handle says so in its log instead of passing silently.
- The handles are minted by a fixed-capacity, self-recycling identity table in the
backend, not by MG_Impl's client allocator. Nothing in P2 frees a VertexElementsCso
or Buffer slot - the frontend has no death notification Magma can hook - so the
allocator's live Allocate and dead Free grew one SlotState plus one hash-map node
per object ever created, for the life of the process. The table is 2-way
set-associative with an LRU victim and a Gen bump on reuse: bounded (32 KB for
VAOs, 128 KB for buffers), exactly as ABA-proof, and it takes MG_Backend's only
include of MG_Impl back out.
- Both per-slot memo tables are now a BIJECTION with that mint rather than a masked
direct map, so two live VAOs cannot share an entry and the eviction decision lives
once, in the identity table, instead of once per consumer table. The density claim
the masked tables rested on was false while slots grew monotonically, and the
masked form had also dropped the second candidate and the frame-serial victim
choice the address-hashed table used to have.
- snap.vaoHandle is stamped only when bit 6 is on. It was guarded by the compile
switch alone, so MOBILEGL_PIPE_PUSH=0 - the all-pull control D14 defines as
reproducing P1 exactly, and the arm D.4.3's T2 is measured on - paid a mint per
new VAO and a compare per draw for a field that arm never reads.
- Every re-keyed Track-H site now asks the same MagmaPipeTrackHArmIsHandles helper,
including VertexInputStateFactory::ComputeHash, which decided for itself before
and could key on the pre-handle identity while its neighbours keyed on the handle.
- The pull build's two pipeline-memo sites keep the base ref's text statement for
statement: G1 is back to the contract's four resized symbols, 0 added/removed.
- P2 D12.5 (ARCHITECTURE.md 9.5). VertexArrayObject carried three `mutable` memos for the
backend: a content hash, a raw pointer into VertexInputStateFactory's heap-allocated
cache entry plus that cache's eviction epoch, and two aux words. A frontend state object
holding the backend's pointer is what P2 retires - under split the backend is in another
process and its cache entry has no address a client could store.
- The hash and state memos move into a slot-indexed table the FACTORY owns, keyed on the
VAO's {slot, gen} and guarded by exactly the same config version, so nothing is
recomputed more often than it was. Fixed and direct-mapped for the same reason m3's
VaoDrawMemo table is: nothing frees a VertexElementsCso slot in P2, so a grow-on-demand
table would keep one entry per VAO ever created. 2048 x 48 B is 96 KB.
- The AUX memo is deleted rather than moved, as the brief says: its two words already live
in VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and GetBackendAuxMemo has no
live reader anywhere in the tree - the only writer was the line this commit stops
executing.
- The eviction-epoch dance shrinks with them. The PROCESS-WIDE s_evictionEpochSource exists
because the memos live on frontend VAOs and therefore outlive the factory; the handle
arm's table dies with the factory, so a per-instance counter is enough there. The epoch
itself stays - it guards the POINTEE, which is still a cache entry a frame boundary can
erase, and moving the memo does not change that. (The brief reads as if a slot-indexed
table removes the need for an epoch; it removes the need for a process-wide one.)
- The three draw-path readers that asked the VAO "is your content hash already memoized?"
now ask whichever side owns the memo, through a force-inlined wrapper so the PULL build's
two loads stay two loads.
- All three accessors and their storage are kept under MOBILEGL_PIPE_LEGACY_MEMOS rather
than deleted from the file, because that is the arm the pre-handle A/B runs (D14) and
because a pull build forces the option ON, where G1 admits no change at all. Configuring
with -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF is what makes the deletion real, and that build
compiles clean - which is the check that nothing else still reaches for them.
- Verification: pull symbol_report --threshold 0 is 0 added / 0 removed / 0 renamed with
the contract's four resizes and no fifth; ctest -L unit 1489/1489 in both the pull and
the push build; ctest -L integration-gpu -R DirectVulkan 432/432 under the default
bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0. The LEGACY_MEMOS=OFF build compiles but
cannot RUN on this tree, and that is the D14 gate working rather than a defect: no
tracker binds a render-state CSO here, so the handle arm has no key and
Fatal{PipeLegacyMemosDisabled} fires at the first draw instead of the memo quietly
aliasing every render state onto one entry. Re-run it once p2/tracker has landed.
- Track H subsystem 4 (P2 brief D12.4, ARCHITECTURE.md 9.5), behind
kMGPipeSubsystemMagmaVertexInput.
- VertexInputStateFactory::ComputeHash's buffer identity component becomes the buffer's
{slot, gen} - "lifetimeId -> gen mixed into every server-side content hash". Both are
equally ABA-proof (the allocator maps one onto the other and bumps Gen only on slot
REUSE); what changes is that the hash now carries the identity the SERVER will be handed
once buffers travel as handles, instead of a number only the client can mint.
- LookupVaoDrawMemo becomes a direct slot index: the slot IS the index, and the whole
validation is one handle compare. Gone with the re-key are the Fibonacci mix of the VAO's
address, the two-way probe, the frame-serial eviction choice and the (pointer, lifetime
id) pair - slots are dense by construction, so consecutive VAOs land in consecutive
entries and the collision the address hash existed to spread does not arise below the
table size.
- The table stays FIXED at 2048 entries and the slot index wraps, where the brief calls
for a grow-on-demand vector. Reason, and it is a tree fact the brief does not carry:
nothing in P2 frees a VertexElementsCso slot. The frontend death notification is Espryt
0b's e2 and it covers Espryt's six kinds; buffers are the only kind with an OnDestroy
hook today. A grow-on-demand table would therefore hold one ~1 KB VaoDrawMemo per VAO
EVER created, which on a chunk-cycling Minecraft frame is tens of megabytes. Above the
table size this degrades to a direct-mapped cache validated by the full {slot, gen}:
never wrong, only colder, and strictly better than the address hash it replaces.
Revisit when object deletion reaches the client allocator.
- SetupDrawSnapshot's VAO identity collapses to the same handle - one compare instead of
(address, lifetime id) - so the snapshot and the draw memo cannot disagree about whether
the VAO moved. The config version stays: it answers a different question.
- Handle acquisition sits behind a one-entry memo in the renderer. Acquiring is a hash
probe into the allocator's lifetimeId -> slot map and LookupVaoDrawMemo runs per draw, so
without it the arm would have swapped the address hash it deletes for another probe; a
run of draws over one VAO now pays a single Uint64 compare. Magma acquires the handles
itself because the tracker does not emit object-class state in P2 (it emits for dirty
bits 0-4 only); when it does, these become reads of what the client already sent.
- Negative control C (MOBILEGL_PIPE_HANDLE_ABA_CONTROL, brief D18) is implemented here
because the two guards it defeats live here: it makes ComputeHash hash the raw
BufferObject* and makes LookupVaoDrawMemo skip the lifetime-id compare - the exact state
the table was in before the ABA fix. It applies to the PRE-HANDLE arm, which is what
HandleRecycleScenario.AbaControl runs (MOBILEGL_PIPE_PUSH=0), and it is what proves that
scenario's reproducer still reproduces instead of passing for the wrong reason.
- Verification on this tree: ctest -L integration-gpu -R DirectVulkan is 432/432 under the
default bitmask and 432/432 under MOBILEGL_PIPE_PUSH=0, and -L unit is green. Pull build
symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the
contract commit's.
- P2 D12.3. ApplyDynamicDrawStateTail keeps reading GetRenderStateParametersVersion, and
under MOBILEGL_PIPE_PUSH that accessor is RE-SOURCED: it returns
PipeInputs::m_renderStateParametersVersion, which the applier publishes from
MGPDynamicState::Version and MGPBindRenderState::Version. The gate now reads what the
client pushed rather than what the backend pulled.
- The brief expects the same change to stop a PIPELINE-only change invalidating the tail.
It does not, and the tree is right against the brief: the applier publishes
bind_render_state's Version into the same counter, and it has to - Espryt's
SyncRenderState uses that counter as its all-state change detector and G5 forbids
touching one line of it, so a bind that rewrote the pipeline half while leaving the
counter still would make Espryt skip re-syncing the state it just changed. Getting the
finer gate needs a second, dynamic-only version on the wire, which is a CONTRACT change;
recorded for the integrator rather than smuggled in here. The second-level DynamicTailKey
compare already absorbs a pipeline-only change at the cost of one key build and no
vkCmd*, exactly as it did before P2.
- The coverage check D12.3 asks for, as static_asserts rather than a unit test: every
RenderStateParameters member DynamicTailKey reads is checked against the P2 chunk table
(MGPipeRenderStateSpans.h), including the three stencil members PER FACE, since D6
splits StencilFaceState through the middle. The tail's hand-written input inventory and
the offsetof-derived chunk table were written for different reasons, so making them
check each other is free evidence, and a chunk edit that demoted one of these is a build
break here instead of a tail that stops being re-run when its input moves. A ctest entry
would have had to live in MG_Test/Pipe/RenderStateSpansTest.cpp, which the ownership
table gives to package A; a static_assert in the file that owns the reader is both
in-scope and stricter.
- ScissorTestEnabledMask is the one input that is NOT dynamic, and the brief says it
should be. The tree wins: the split's only rule is "pipeline iff a setter that calls
BumpVersions writes it", and SetCapability(ScissorTest) does, so it sits in pipeline
chunk P6 with the other capability bools. It is pinned with the assertion INVERTED, so
demoting it - which would be a real G7 violation - is also a build break. Reading it in
the tail stays harmless because BumpVersions moves both counters together.
- Push-only: the whole block is inside MOBILEGL_PIPE_PUSH and the pull build is unchanged
(symbol_report --threshold 0: 0 added / 0 removed / 0 renamed, 4 resized, all four the
contract commit's).
- P2 D12.1. GetOrCreatePipeline's memo compared a VALUE hash of the pipeline-relevant
fixed-function state that Magma recomputed itself. After P2 the CLIENT hashes exactly
those bytes when it mints a content-addressed render-state CSO
(MGPipeComputePipelineSubsetHash over the seven pipeline chunks), so the bound CSO
handle IS that key and ComputePipelineStateHash was doing the boundary's work twice.
The client's pipeline subset is a strict SUPERSET of the 24 members the hash read, so
the handle discriminates at least as finely as the hash it replaces.
- renderPassHash STAYS in the key, and that is load-bearing rather than conservative:
ComputePipelineStateHash was never a pure function of RenderStateParameters - its
signature took colorAttachmentCount and rasterizationSamples, and
ResolveEffectiveSampleMask reads the latter - so those two render-pass facts have to
stay separated by something. entry.renderPassHash already separates them (the pass
hash folds each attachment's sample count and the attachment set), which is why
collapsing the state half onto a handle loses no discrimination.
ResolveEffectiveSampleMask is NOT deleted with the hash: it is a payload computation,
and it keeps reading Multisample / SampleMask / SampleMaskValue out of the working
block.
- Both memo probes are re-keyed, not just the full path's: TrySetupDrawFastPath carries
its own copy of the probe, and a fast path that keyed differently from the full path
would hand back a pipeline the full path would not have matched.
- The arm is chosen at runtime, per D14: kMGPipeSubsystemRenderState in the
MOBILEGL_PIPE_PUSH bitmask AND a non-null bound CSO. The second half is not belt and
braces - a tree whose tracker does not emit create/bind_render_state yet has no handle
to key on, and keying every draw on the null handle would alias every render state onto
one memo entry. Falling into the pre-handle arm with Features.PipeLegacyMemos=0 is
Fatal{PipeLegacyMemosDisabled}, so HandleRecycleScenario.Handles cannot go green by
quietly running the old code.
- ComputePipelineStateHash and its five cached-hash members (m_pipelineStateHash{,Valid,
Version,ColorCount,SampleCount}) survive only under MOBILEGL_PIPE_LEGACY_MEMOS, which
a pull build forces ON: they exist purely to avoid re-hashing, and the handle arm never
hashes. InvalidatePipelineMemo loses them on the same condition.
- New MagmaPipeArms.h holds the two-switch arm selector shared by the P2 Magma re-keys.
- G1, pull build, symbol_report --threshold 0 against ~/w7/p2-before-libMobileGL.so:
0 added, 0 removed, 0 renamed, 4 resized - and all four are the CONTRACT commit's
(RenderState::{RenderState,SetCapability,IsCapabilityEnabled} and
_GLOBAL__sub_I_DirectGLES.cpp). This commit adds none: every edit is inside a
MOBILEGL_PIPE_PUSH arm and the pre-handle statements are left where they stood, which
is why ResolveBoundRenderStateCso is push-only rather than a shared helper - an earlier
shared-helper shape moved 104 bytes of GetOrCreatePipeline around for no behaviour
change and the gate saw it.
- 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.
- the write analysis under-approximated in the exact direction its own claim forbids:
written_tokens recorded a write through a member's field (m_foo.bar = v) as FIELD:bar
and never as MEM:m_foo, while resolve_reader resolves a shutter's accessor to
MEM:m_foo, so for any struct-valued member the two halves could not meet; a new
MEMBER_ROOTED_WRITE_RE records both, for m_x.f, m_x[i].f, m_x->f and nested
- it could not see RenderState::SetPixelStoreParam's sixteen writes twice over, because
they are spelled with the token-pasting operator and the file was read raw - the
"field" it recorded was the macro parameter name, paramNameTail. The derivation now
expands the function-like macros defined under its two roots (directives blanked,
parameters substituted, ## pasted), which is also what makes SET_CAPABILITY's
m_parameters.capability##Enabled writes visible
- and it now DECLINES rather than answers wherever it cannot say it read every writer:
a body carrying a construct it does not model (an unexpandable token paste), anything
that reaches such a body through the call-graph fixed point the writes already travel,
and any shutter member with a write-shaped occurrence outside the analysed roots.
--check prints every decline with its site, plus how many bodies and files the absence
claim rests on and the one place it stays coarse
- the BitwiseEqual bits' shutter window now also starts at the last `}` before the
`dirty |=`, so the pack block's trailing `m_pack = pack;` no longer leaks the pixel
store into NEW_PATCH_STATE's reader set
- consequence in the map: X(SetPixelStoreParam, NEW_PIXEL_PACK) went from a verdict the
gate could not support - no function name in the tree could carry that bit - to an
accepted, checked answer, and the row it forced (kPulledEveryVerb, documented as "no
shutter exists, and none is needed yet") said that of the only mutator behind the
shipped set_pixel_pack_state. The row is now kPulledPartialShutter|NEW_PIXEL_PACK: the
pull is what holds on every mutating path, the bit moves on the eight Pack arms, and
both facts are machine-readable for the P3a reader D16 writes this file for
- --self-test grows from 7 negative controls to 10 - kPulledPartialShutter naming no
bit, a mutator whose write analysis is incomplete, and a shutter member written
outside the roots, the last two asserting a DECLINE and no verdict - and gains a
positive control that fails if SetPixelStoreParam's pasted writes ever go unread again
- X(SetNamedTransformFeedbackBinding, NEW_SO_TARGETS) was false on EVERY path: that
mutator binds a BufferState binding point or writes a saved-bindings entry, while the
bit's shutter mixes the buffer-CONTENT aggregate with the transform-feedback
generation, and a binding moves neither. It answers kPulledEveryVerb, which is what
reaches the backend today (GetBufferBindingPoint, in the class's may-read mask).
- X(SetPixelStoreParam, NEW_PIXEL_PACK) was false on the eight Unpack arms: the setter
writes both halves and the tracker's bit 2 is a byte compare of the PACK half alone,
because set_pixel_pack_state deliberately has no unpack counterpart. It answers
kPulledEveryVerb, the one publisher every arm has.
- --check no longer rubber-stamps the 28 rows the RenderState derivation cannot reach.
It reads Tracker.h's Update() for what each bit's shutter READS, resolves those
accessors through MG_State's getters to the members behind them, computes what every
mutator transitively WRITES as a fixed point over MG_State/GLState and MG_Impl/Pipe
(expanding MGP_NOTE_AGGREGATE through MGPipeNoteAggregate's own switch rather than
assuming the hop), and fails a row naming a bit whose shutter its mutator moves on no
path. One-directional by construction: the write analysis over-approximates, so it can
prove absence and not presence, and absence is the under-firing direction.
- the enumerator spelling and the NEW_* spelling are paired BY POSITION out of Tracker.h,
so the enum and kMGPipeDirtyNames drifting apart is itself a gate failure.
- two more self-test negative controls, one per family, both built from the defect that
was really in the file; 7 controls now, all tripping.
- --check prints what it did NOT check: how many rows carry a prose answer, and every row
the derivation declined, so "all mapped" cannot be read as "all verified".
- render_state_publishers() folds the bodies of one name with INTERSECTION, so two
overloads - one BumpVersions, one bare ++m_version - can no longer derive as "both
always fire" and bless an under-firing row.
- the header states what "every path" means: every path that MUTATES, so a redundant-write
guard does not make its publisher conditional, while a publisher reached on only some
mutating paths must not be named.
- the residual value block armed on NEW_PIPELINE_STATE, so glEnable(GL_CLIP_DISTANCE0)
never re-armed it: SetCapability's ClipDistance0..7 arms are deliberately not
BumpVersions() and those eight are 8 of the 35 CapabilityInputs the block carries, so
the D9/G10 trip wire was disarmed for them for an unbounded window - and invisibly, a
block that is never emitted cannot diverge. It now arms on either render-state counter,
the same answer DirtySurface.def derives for SetCapability, and the comment that
asserted the opposite ("every SET_CAPABILITY arm calls BumpVersions") is corrected.
- the arming moved outside the residual subsystem gate: whether the capability set may
have moved is a fact about the frontend, not about which subsystems this build pushes.
- set_vertex_attrib_defaults published NOTHING across a context change. Tracker::Reset()
sets the staging mirror to the GL defaults and a fresh GLContext holds the same, so the
per-attribute diff was empty on the one walk that must publish a COMPLETE state, while
MGPipeApplierReset() leaves gPipeInputs.m_currentVertexAttribute holding the previous
context's values - which cancelled, two lines later, the InvalidateAll() written for
exactly that case. It now sends all 32 when the tracker is freshly primed, the arm
EmitRenderState already had.
- the fresh-context reset of the CSO cache and the applier moved out of EmitRenderState,
which runs only when bit 0 of MOBILEGL_PIPE_PUSH is set: the per-subsystem A/B D14
invites gave a fresh context a never-reset applier while every suppressor slot was
invalidated.
- MGPipeVertexAttribDefaultsLastHeader() is the observable for both properties of that
call that cannot be read back without a poisoned read of m_currentVertexAttribute.
- the repair case now asserts the invariant (the call named exactly what moved, at most
one repair) instead of repairs == before + 1, which pinned today's applier and would
have gone red the day package A honours MGPAttribValue::ValueClass.
- a static_assert that no MGPipeDirty bit owns kMGPipeSubsystemResidualValues, which is
what makes the residual block's direct subsystem test the one safe exception to
MGPipeSubsystemForDirty, and a note that the NEW_PIPELINE_STATE/NEW_RENDER_STATE
MOBILEGL_ASSERT is a debug/verify alarm over behaviour that is safe in every build.
- the kUnpublishedDestroy note said the three kinds were outside the six D13 scopes explicit
destroy to; the tree's reason is sharper and does not depend on reading D13's prose one way
or the other: those three have no per-object handle on the wire at all, so their DirectGLES
twins are still reclaimed by the backend's own registry teardown
- records the brief/tree disagreement in place: D13 says "six kinds" while the Core.cpp line
ranges it cites also cover MarkProgram/MarkShaderForDeletion
- set_vertex_attrib_defaults hard-coded MGPAttribValue::ValueClass to 0 for every attribute
and always sent the FLOAT view's four words. A CurrentVertexAttributeValue is one value in
three views and GLContext converts numerically between them, so those bytes cannot
reproduce the frontend value: glVertexAttrib4f(loc, 1.5f, ..) leaves 1 in intValue and
0x3FC00000 in floatValue, and every glVertexAttribI4i/ui default was wrong too
- GLContext now records which view each glVertexAttrib* write filled directly
(GetCurrentVertexAttributeClass, push-only) and the payload carries that class and THAT
class's own words. It is kept beside the value rather than inside it because
CurrentVertexAttributeValue is mirrored into PipeInputs and compared there by a memcmp
whose size assertion lives in a file this package does not own
- MGPipeFillAttribValue is the flattening, in one named place, so TrackerAttribPayload can
pin it: the old defect turns three of its four cases red
- the applier (package A's) still memcpys the four words into all three views regardless of
ValueClass, so the emitter now CHECKS: it compares the mirror the applier wrote against the
frontend's value and, when they differ, copies the field itself and says so once. That
closes the window the old code left wrong - a glVertexAttrib* write followed by a verb
whose class does not read the field, where the residual fill does not run for it - and it
stops repairing by itself the day A's applier honours the class
- MGPipeVertexAttribDefaultRepairCount() makes that repair observable to a test without
reading storage the fill table forbids that verb to read
- the emission gate now goes through MGPipeSubsystemForDirty, the one bit-to-subsystem map,
instead of a second copy of it written out by hand at the validate point; five
static_asserts tie that map to the field-emitter map it has to agree with
- the staging mirror is advanced only by the branch that sent dynamic bytes, with the
invariant it used to rely on (BumpVersions moves both counters, RenderState.h) asserted
here rather than assumed of another package's file
- TrackerShippedEmitter drives MGPipeValidateForVerb itself and reads the real singletons
back, so the blend-toggle and viewport shapes are pinned on the shipped emitter and not
only on the unit tests' local re-implementation; the fixtures reset the applier, the cache
and the tracker together, which is the only consistent state of the three
- comments: the derivation probe is a one-field sample, the residual block's trip wire is
half a tautology until package A's c1 lands, and the widened counter cannot see a change of
exactly 65536 - all three recorded where the code is, not only in a review
- Counters::Binds was declared, documented as one of the three numbers P13's capacity retune
reads, and incremented nowhere: the retune would have read a permanent zero, and the unit
tests counted binds in a local of their own
- counted in Acquire, which has exactly one caller and is followed by a bind_render_state
every time, so the count cannot drift from the emitter forgetting to tick it
- SetCapability named NEW_PIPELINE_STATE, but its ClipDistance0..7 arms write
ClipDistanceEnabledMask (dynamic chunk D7) and deliberately do not BumpVersions, so that
publisher does not fire at all for glEnable(GL_CLIP_DISTANCE0); SetStencilFunc named it
too, while ++m_pipelineStateVersion there is conditional on Func moving, so a ref-only
glStencilFunc does not move it either. Both are now NEW_RENDER_STATE, the answer that
holds on every path
- a row may now carry several publishers joined with '|', which is what lets the 18 setters
that call BumpVersions on every path state both counters, and the patch trio state its own
bit and the two render counters it also moves
- --check no longer validates only row existence and answer vocabulary: it reads
RenderState.cpp, derives per setter which of the two counters moves on EVERY path
(BumpVersions moves both, a bare ++m_version only the first, a setter with both kinds of
path only the first, a delegating setter inherits its callee's) and fails when a row
claims a publisher that under-fires or omits one that always fires
- two new self-test negative controls, one per direction, both built from the defects that
were actually in the file
- MarkProgram/MarkProgramPipeline/MarkShaderForDeletion answered kExplicitDestroy, a
mechanism D13 does not build for them: Espryt 0b's explicit destroy is scoped to six
object kinds that exclude programs, pipelines and shaders. They answer kUnpublishedDestroy
now - a recorded hole rather than a mechanism that does not exist
- G2 requires the pull and push ctest name sets to be identical, name for name. The two new
suites had a single hand-written "SkippedInAPullBuild" placeholder each, which made the pull
build 30 names short - a diff G2 exists to catch.
- Each file now carries an X-macro list of its push-only suite.name pairs, expanded in the
pull branch into cases that GTEST_SKIP. A case added on one side and forgotten on the other
is a visible ctest-name diff rather than a test that silently is not there.
- Measured with a CORRECTED gate command. The brief's G2/G14 grep is '^\s+Test #', which only
matches a four-digit test number: ctest right-aligns the number, so tests 1..999 print as
"Test #7:" with more than one space, and on this tree that silently dropped 999 of 2368
names - i.e. the gate as written passes while looking at 58% of the list. The pattern that
works is '^ +Test +#[0-9]+: '. Both gates are green under it: 2396 names in the pull build
and 2396 in the push build with a zero-line diff, and zero of the 2363 baseline names gone.
- TrackerWalk drives the tracker and the cache DIRECTLY rather than through
MGPipeValidateForVerb: the validate point reaches the library's one process-wide tracker, and
a unit test that asserts on a shared singleton fails the moment ctest runs the suite in
parallel. The three lines of emission logic it reproduces are the same three lines.
- BlendToggleReusesTwoCsos is the Blaze3D shape the whole "push at validate, not in the
setter" decision was made for: 32 enable/draw/disable/draw pairs mint exactly TWO CSOs, bind
64 times and hit 62. A per-setter design would show up here as 64 mints.
- ViewportDoesNotMintACso is the regression RenderState.h records: 16 glViewports mint nothing,
never move the pipeline version, and each sends exactly chunk D0 - not the other seven.
- WrapAroundRePushesButNeverMisses drives m_version past 65535 and asserts every one of 70000
changes fired. The alternating value deliberately never touches the default: a setter that
early-outs would otherwise make the first iteration a false miss and hide a real one.
- AggregateGenerationCatchesABoundTextureMoving is the first test of the direction the P1
verify comparator cannot see - it compares object-class fields by identity only, so a bound
texture whose content moved looks unchanged to it. The bit fires and then settles, so it is a
shutter and not a stuck flag.
- ANaNPatchLevelEqualsItselfAndDoesNotFireForever: a NaN outer level is a legal
glPatchParameterfv value, float equality says it differs from itself and a byte compare says
it does not. That is why the shutter is a memcmp.
- ThePixelPackShutterIsAByteCompareOfThePackHalfOnly asserts an UNPACK write does not move the
pack shutter, which is the half that deliberately has no carrier.
- HashCollisionDoesNotAliasTwoStates needed a seam and got one: MGPipeCsoCache::s_hashForTest,
null in every real build, one never-taken branch on a path that runs only when the pipeline
version moved. Without it the memcmp confirm is unreachable code that nothing can prove is
doing anything, and what it stops - two different render states on one CSO - is silent wrong
pixels with no gate that can see it.
- ContentAddressingOffMintsEveryTime pins that bit 63 really changes mint/reuse behaviour, so
the negative control cannot rot into a dead switch.
- The set-hash suppressor is exercised on all seven slots even though P2 wires one, including
the reserved-zero contract: a computed hash of 0 is remapped to 1 so it is never confused
with "never emitted".
- MG_Pipe/DirtySurface.def: 73 rows, one per distinct mutator the scanner finds, each answering
"what publishes this". The answer vocabulary is a MGPipeDirty bit name or one of five
non-bit answers, and each of the five is documented in the file's header rather than left to
be inferred: kImmediate, kReverseChannel, kNoBackendRead, kExplicitDestroy and
kPulledEveryVerb. Where a mutator has more than one true answer the row carries the COARSER
one - the one that cannot under-fire.
- gen_pipe_dirty_surface.py --check is the gate and it fails in BOTH directions: an unmapped
mutator renders stale, and a row naming a mutator the scan no longer finds keeps a real hole
looking covered. It also rejects an answer that is neither a documented non-bit answer nor a
bit name read out of Tracker.h's own kMGPipeDirtyNames, so a renamed bit cannot leave a row
silently pointing at nothing.
- --self-test runs three canned negative controls - a withheld mutator, a stale row, a bad
answer - and each must trip; trips == 0 is itself an error, the shape
check_include_closure.py and gen_pipe.py --self-test already use. ROADMAP.md's rule is that
every gate must be able to go red for the reason it exists.
- --summary keeps working unchanged, because the CI file that still calls it belongs to
another package until it lands.
- The human report prints the mapped answer where it printed UNMAPPED.
- FillPoints.def: the verdict on the eight statically over-approximated rows, recorded per
group in the def's own comment. All eight are KEPT and the reason is the same in all three
groups - each row names a concrete backend path (the depth/stencil read emulation's paused
capture, VkClearManager::PreCompensateSrgbClearColor's GL_FRAMEBUFFER_SRGB read, the shader
blit's viewport / provoking vertex / binding-point reads), and the only evidence that could
retire one is dynamic. A corpus that never reaches a path proves nothing about it, and a row
dropped on that basis turns a rare path into Fatal{UnmigratedPipeInput} in a shipped build.
The contract's new FramebufferSrgb storage in fact makes one of the eight MORE load-bearing
than it was, not less: it used to read a compile-time constant.
- set_residual_value_state emits the 35 capability bits, read from the FRONTEND's own
IsCapabilityEnabled rather than from the assembled mirror. That direction is the whole
design: the applier then compares the carried answer against the assembled one, so the block
is an independent oracle instead of a tautology - which is the failure the P1 entry compare
had and P2 is paying to remove.
- It goes out AFTER the residual fill, not with the other emissions: the mirror the trip wire
compares against is written either by the applier's derivation or by that fill, so before it
the block would be compared against the previous verb's answer.
- It is HELD, not dropped, when the verb's class does not carry IsCapabilityEnabled. kQuery and
kXfbSpan do not read it, so at those verbs the mirror is stale by construction; a capability
that moved between two queries would silently disarm the wire if the emission were skipped
instead of deferred.
- ByteClass::ResidualValueBlock has been a placeholder that "stays at 0 until P2" since P0.
This makes it non-zero, which is half of G10.
- The wire is not theoretical: the first version of this commit fired it for real -
Fatal{PipeResidualDiverged, "Dither"} carried=1 assembled=0, on every verb whose class does
not read the capability mirror - and that is what the holding latch above is for. GL_DITHER
defaults to enabled, so the very first mismatch the block could have found is the one it
found.
- integration-verify 818 green, integration-gpu 878 green under the default bitmask and again
under MOBILEGL_PIPE_PUSH=0, unit 1499 green.
- set_pixel_pack_state on NEW_PIXEL_PACK, set_patch_state on NEW_PATCH_STATE,
set_vertex_attrib_defaults on NEW_VERTEX_ATTRIB_DEFAULTS, each gated on its own runtime
subsystem bit so the bitmask stays a per-subsystem A/B.
- MG_Impl/Pipe/SetHashSuppressor.h: seven slots, one per kVarTail set_*, with
SetVertexAttribDefaults the one P2 wires. 0 is reserved for "never emitted" and a computed 0
is remapped to 1, so the first emission always goes out. The other six are the carrier for
the ~175 lines of backend debounce that move in P3b/P4b; landing the mechanism now means the
shape is pinned by a test rather than by a plan.
- The var-tail carries only the attributes that differ from the tracker's mirror, underneath
the set-hash suppression of the whole resolved set - the two suppressors answer different
questions and both are cheap.
Two rows of Coverage.def's emitted list CANNOT yet retire their pull, and each says why in the
code rather than being silently absent:
- GetPixelStoreParameters is BOTH halves of the pixel store and set_pixel_pack_state
deliberately carries only PACK, so the unpack half has no carrier at all. The field keeps
being pulled and the verify comparator keeps proving it.
- GetCurrentVertexAttribute's three views are not bit-identical - GLContext CONVERTS between
them - while MGPipeApplySetVertexAttribDefaults memcpys one Data[4] into all three and
ignores MGPAttribValue::ValueClass, which the wire type carries precisely so it does not
have to. Until that applier reads ValueClass the carrier cannot reproduce the frontend
value. The call is still emitted, so the wire shape, the payload bytes and the suppressor
are all real; the residual fill runs after emission, so the mirror ends up correct either
way.
Both are contract-side defects in files this package does not own; they are reported to the
integrator with the exact fix rather than worked around here.
- MG_Impl/Pipe/CsoCache.h: 64 entries, LRU, hash -> probe -> MEMCMP -> handle. The memcmp is
not optional: a bare 64-bit hash equality would let a collision alias two different render
states onto one CSO, which is silent wrong pixels with no gate that can see it, and Mesa's
cso_cache memcmps for exactly that reason. It runs only when the pipeline version moved, so
never in the steady state. Eviction emits delete_render_state and frees the client slot.
- kMGPipeBehaviourNoCsoContentAddressing (bit 63) turns off the PROBE and the handle reuse,
not the records: every pipeline-version change then mints, binds and evicts, which is the
whole-block content addressing the design is measured against.
- The validate point's step 3: bind_render_state when the pipeline version moved (12 bytes, no
hashing, no blob when the cache hits), set_dynamic_state when m_version moved, carrying only
the dynamic chunks that differ from the tracker's staging mirror. An EMPTY chunk mask still
sends the 32-byte header, because the version is what Magma's dynamic tail gates on and it
moved.
- The residual fill now skips a field a P2 call supplies, driven by the generated
kMGPipeFieldEmittedBy[] and gated per subsystem on the runtime MOBILEGL_PIPE_PUSH bitmask, so
the bitmask is a true per-subsystem A/B. THE STAMP IS UNCHANGED: a stamp says "this verb
published this field", which is as true of an emitted field as of a copied one, and
withholding it would abort every backend read of the fields the migration just took over.
- PipeStats::RecordDrawPayloadBytes has been implemented, unit-tested and called by nothing
since P0. This is its first emitter.
- A field that reaches PipeInputs only through MGPipeDeriveRenderStateFields is skipped only
when that derivation is really there. It is package A's and is a declared stub on the
p2/contract tag this branch starts from, so rather than hard-code which branch this is, the
filler probes once: a sentinel in a scratch block, the mirror cleared, the derivation run,
the answer latched. It stays useful after A lands - if the derivation is ever deleted the
filler degrades to PULLING those fields rather than rendering a default.
- integration-verify, 818 entries, green: the comparator re-reads every field from the live
context at every backend read, so "the assembled block equals the live context" is now
proven rather than asserted, and the entry compare has stopped being a tautology.
- MGPipeFillForVerb becomes MGPipeValidateForVerb and MGP_FILL expands to the new name. The
macro spelling, the 83 call sites and the verb enum do not change: the dispatch is
kMGPipeVerbClass's nine classes, which is the same code as nine named ValidateFor* entry
points with one call site per verb instead of nine (P2 brief D1, against ARCHITECTURE.md's
eight - FillPoints.def argues in its own comment for splitting kProgramOp out, and the landed
table is what runs).
- MG_Impl/Pipe/Tracker.h: MGPipeDirty's 18 bits, the widened Uint16 shutters, the per-verb walk
and the per-bit-per-verb-class fire tallies. The widening happens in the TRACKER and MG_State
is not changed for it; a wrap costs one extra re-push and never a missed one.
- NOTHING IS EMITTED YET. The mask is computed, latched and counted, and the full P1 residual
fill runs after it unchanged. That is the point of this step: it says the walk is
semantically free before any field stops being pulled, so a regression in the next commit
cannot be blamed on the walk.
- Every shutter over-fires on purpose. Bits 2 and 3 are BYTE compares, not value compares,
because a NaN patch level is a legal glPatchParameterfv value and has to equal itself; bits
5..17 are composed with a mixing hash, which can in principle collide, and that is stated in
the file and is acceptable only because nothing consumes those bits in P2.
- The shutter for bits 6..8 reads the current program's version counters WITHOUT
GetProgramForDraw, so the walk never joins a pending link to answer "did the shader move".
- Header-only rather than Tracker.{h,cpp}: the root CMakeLists.txt that would have to name a
new .cpp belongs to package A and is frozen behind the p2/contract tag. One translation unit
in the library includes it, so inline costs nothing, and splitting it out is one list(APPEND)
line whenever the ownership allows.
- MG_Test/ScopedPipeVerb.h and MG_Test/Pipe/PipeInputsTest.cpp follow the rename. Five comment
references to the old name live in package A's files (MGPipe.h, the two generated .inc, and
gen_pipe.py) and are deliberately left for their owner.
- MGP_NOTE_AGGREGATE(Aggregate) next to MGP_NOTE_MUTATION in MG_Pipe/PipeMutation.h, ((void)0)
in the pull build for the same reason and with the same shape. It answers a DIFFERENT
question from MGP_NOTE_MUTATION - "did any object of this class move since the tracker last
looked", not "did a backend move a frontend value inside its own verb" - which is why it is a
second macro rather than an overload.
- The counters are members of the owning MG_State container (VertexArrayState,
FramebufferState, TextureState x2, BufferState) and are reached through a push-only GLContext
facade, because the bump points sit on OBJECTS and an object has no back-pointer to the state
that owns it. That is the free-function form P2 brief D4 allows, and it costs a global load on
a path that has just written object state.
- A SIXTH aggregate, VertexAttribDefault on GLContext, which D4 does not list. Its bit
(NEW_VERTEX_ATTRIB_DEFAULTS) is specified there with a ContentHash over all 32
CurrentVertexAttributeValues, and hashing 768 bytes on every draw does not fit inside the T1
ceiling the same brief pins. The hash still decides whether to EMIT (D11's set-hash
suppressor); the generation decides whether to hash at all.
- 30 bump points: 3 VertexArrayObject config-version sites, 3 FramebufferObject object-version
sites (one of them inside MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER, so the statement
carries its own line continuation), 5 texture content-version sites, 12 texture
params-version sites, SamplerObject::BumpVersion as the sampler choke point, 7 BufferObject
change-serial sites and the 3 glVertexAttrib* defaults.
- Every counter is deliberately COARSER than the state it guards: over-firing costs one extra
push, under-firing renders stale, and under-firing is the direction ARCHITECTURE.md 13.2
names as the dangerous one and the P1 verify comparator cannot see for object-class state.
- TrackerTest: each bump point moves ITS aggregate and no other, plus a null-context note.
- G1: the pull build is 0 added / 0 removed / 0 renamed and the four resized symbols are the
contract commit's own, unchanged by this commit.
- TheArmlessCasesLeaveTheLogPathAndTheConfigAsTheyFoundThem drives ScopedLogFileRedirect and ScopedArmlessKnobPair with and without a pre-set MOBILEGL_LOG_FILE_PATH and asserts the restore byte for byte
- in a build without the legacy arm the verdict is Handles whatever the knobs say, so the case pins the knob values rather than the NoArm verdict there
- BackendSlotTable links every instance into a per-type holder list; OnFrontendObjectDestroyed resolves the handle once, drops the twin in each holder by handle and frees the slot once, last - a notice delivered to one registry left the fixture's saved copy holding a dead twin, and its driver storage, for the life of the process (review v4 minor 2)
- ReclaimDeadSlots and CollectGarbageNow leave the slot table: nothing called them, so the header's "backstop" was a claim without a caller; the registry's pre-P2 CollectGarbageNow is a no-op on the handle arm (minor 1)
- HandleOf no longer memoises a null answer, which a second holder's acquire could never refresh (minor 8)
- EnsureProcessTeardownSentinel is armed by the slot table's first insertion, as D13 says; the registry arms it only on the legacy arm (minor 9)
- new SanityTest cases: OneDeathNoticeDropsTheTwinInEveryHolderOfTheKind, ASavedCopyOfARealRegistryDropsTheTwinOnTheSameNotice, ANegativeLookupIsNotCachedAcrossAnotherHoldersAcquire, and EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs InitPbufferSurface under the pair in a forked child and fails naming both knobs if InitDisplayAndContext ever stops there again (minor 4)
- AnArmlessKnobCombinationStopsInsteadOfSkippingTheLane writes a per-process, per-case log path and restores MOBILEGL_LOG_FILE_PATH and MG_Config::Features through RAII guards on every exit path (minor 5)
- ForEachLive walked with a range-for and handed fn a reference INTO m_slots, so a callee
that reached GetOrCreate on the same table would resize the vector under both. Index loop
and a copied twin, the shape ReclaimDeadSlots already uses. The one caller today happens
not to insert; that is not a property the walk should depend on.
- GetOrCreate(nullptr) reset the parking twin on EVERY call, so a second null call destroyed
what the first was handed. The map arm kept its null-keyed entry until a sweep, so this
was an arm difference in the one path (SyncTextureObjectToBackend) that documents relying
on the tolerance. It now keeps the parked twin, and the case makes a second call.
- The one-entry memo's comment claimed the three per-draw resolution paths ask for the same
object every draw. Two of them do not: BindCurrentFBO resolves both targets in a frame and
ResolveUnitSamplerBackend asks per texture unit, so both thrash a single-entry memo and
pay a probe P1 did not. The comment now says so and names the fix (per-unit / per-target)
and the gate that would price it (G11, device-side, owed).
- HandleOf caches a NULL answer too - deliberate, because a bound-but-never-synced object
would otherwise re-probe every draw - and what makes it safe is that GetOrCreate refreshes
the memo. Nothing pinned that; RepeatedLookupsOfALiveObjectKeepOneHandle now does.
- Removed the dead #if MOBILEGL_PIPE_PUSH nested inside #if MOBILEGL_PIPE_PUSH in
ScopedDetachedTextureFramebufferAttachments.
- The D13 "must not break" pins made ZERO slot acquisitions: the scratch-FBO scrub and the
three context-generation guards build their twins with MakeShared directly or under
ScopedStateGuardMocks, so they never reach StateBackendObjectRegistry and pass
identically on both arms. The four acquisitions in the binary were all kind Texture, and
the eleven DirectGLESSlotTable cases drive BackendSlotTable directly on the throwaway
kinds Query and Fence - so they would pass had the six registries never been re-keyed.
Framebuffer, Renderbuffer, SamplerCso, ShaderCso and VertexElementsCso had no case that
could go red for the switch-over.
- EverySwitchedOverKindResolvesItsTwinThroughTheHandleArm walks all six through the real
registry global the shipping paths call: GetOrCreate mints a non-null {slot, gen}, Find
and FindByHandle name the same twin storage, the object's own destructor notice frees the
slot with no sweep, and the successor lands on the freed slot with a moved Gen while the
predecessor's handle resolves to nothing. A kind still on the legacy arm answers the null
handle and fails the assertion by name.
- Evidence: gdb breakpoint on MGPipeSlotAllocator::Acquire counts 12 hits for this case
alone in build-push - six kinds times the two objects each - against 4 for the whole rest
of the binary. It skips visibly on the legacy arm and in the pull build (G2 parity).
- e2 was landed for two of six kinds, so Texture, Framebuffer, SamplerCso and
VertexElementsCso still discovered death in a sweep and ROADMAP.md:18's "delete the GC"
was undelivered. TextureObjectBase (the one base every concrete texture derives from),
FramebufferObject, SamplerObject and VertexArrayObject now raise
NotifyStateObjectDestroyed from their destructor, on RenderbufferObject's pattern: out of
line, declared only under MOBILEGL_PIPE_PUSH, so the pull build keeps its implicit
destructor and its symbol set (G1 still 0 added / 0 removed / 0 renamed and 0 resized
against p2/contract).
- With all six announcing, BackendSlotTable loses BOTH sweep drivers: no draw tick, no
creation tick, no kGCInterval / kCreationGCInterval / m_gcTick / m_creationTick.
CollectGarbageIfNeeded() is empty on this arm; CollectGarbageNow() stays as an EXPLICIT
collection and is the backstop for a notice that InProcessTeardown() drops.
- The seven CollectGarbageIfNeeded call sites in DirectGLES.cpp keep their spelling because
they are the legacy registry's driver and that arm is still compiled beside this one; the
registry's body is now guarded on MOBILEGL_PIPE_LEGACY_MEMOS, so a build without the
legacy arm has no collector at all. On the handle arm each site is a predicted branch.
- The weak_ptr per entry stays for exactly two jobs it is honest about: ForEachLive()'s
strong hand-over to ScopedDetachedTextureFramebufferAttachments, and the explicit
collection. It is never an identity test; Gen is.
- Tests: AProgramAndARenderbufferAnnounceTheirOwnDeath becomes
EveryReKeyedObjectClassAnnouncesItsOwnDeath and drives all six classes, by membership
rather than count because every texture owns a private sampler that also announces;
ObjectChurnAloneDrivesTheSweep becomes
AnnouncedDeathKeepsObjectChurnFromAccumulatingWithoutASweep and pins that 256 churned
objects hold one live twin at a time with no CollectGarbage* call anywhere.
- Fatal{PipeLegacyMemosDisabled} was raised from InitDisplayAndContext(), i.e. from inside
eglMakeCurrent. The integration harness pre-flights that exact sequence in a forked child
(MG_IntegrationTest/Harness/HeadlessGL.cpp) and reports a child that dies on a signal as
"no usable GPU/display/ICD", so every scenario SKIPPED and ctest called the lane 100%
passed while running nothing - on the very pair of env vars the D14/D18 A/B is driven
with. ROADMAP.md:7 forbids a gate that cannot go red for the reason it exists.
- The arm decision becomes a pure function of the two knobs, ClassifyEsprytSlotArm(), with
three verdicts. Bring-up now calls DiagnoseEsprytSlotArm(), which names both knobs at
ERROR and RETURNS; the stop stays in ResolveEsprytSlotTablesArm(), which the inline latch
reaches at the first twin lookup - a scenario body, where a crash is a test failure.
- A process that never looks a twin up never needs an arm and is no longer stopped by one
it would not have used. That is the only behaviour this moves.
- SanityTest gains an always-on case: the four knob combinations of the pure classifier,
that the diagnosis does not stop, and that the stop is SIGABRT whose log line names
PipeLegacyMemosDisabled, MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS=0 and the bit -
the message and not merely the signal, because "Subprocess aborted" alone tells an
operator nothing. It skips visibly in the pull and no-legacy builds (G2 name parity).
- UnitBindingsSnapshot was split by #if MOBILEGL_PIPE_PUSH, so a push build ran P2's
lifetime-id debounce on the MOBILEGL_PIPE_PUSH=0 arm too. That arm has to reproduce P1
(ConfigLoader.cpp), or the integrator's A/B measures this slice's mechanism on both sides
and attributes it to neither - the same complaint g_fbSlotCache was already fixed for. The
snapshot now carries P1's WeakPtr fields beside the lifetime ids whenever the legacy arm
is compiled, and Capture/Unchanged pick by EsprytSlotTablesEnabled(). The two answers are
equivalent (OwnerEquals on two empty pointers is true and LifetimeIdOf(nullptr) == 0 == 0;
a live-versus-expired control block and two distinct lifetime ids both compare unequal),
so this is A/B fidelity, not a behaviour change, and a build with no legacy arm carries
neither the fields nor the branch.
- TwoTablesOfTheSameKindShareOneSlotAndKeepTheirOwnTwin deliberately never swept, so it left
a live MGPipeKind::Query slot behind for good, and ObjectChurnAloneDrivesTheSweep reads
HighWater/LiveCount of that same process-global kind. Deltas made them pass today, but
--gtest_shuffle or a third case on kind Query would have made them interact. The
two-holder case now has a kind to itself and returns its slot at the end.
- Its comment claimed two live tables of one kind "cannot arise outside this case". They
can and do: ScopedDirectGLESTextureBindings holds a second live table of kind Texture, and
package D's subsystem 4 re-keys VaoDrawMemo out of the same per-kind allocator. The
comment now states the real hazard (whichever holder frees first orphans the other's
entry; safe, because Free is generation-guarded and FindByHandle compares Gen, but not
free) and flags it for the integrator.
- P2 step e2, as far as the file-ownership table lets one package take it. New frontend
header MG_State/GLState/StateObjectDeathNotice.h carries BufferBackendOps' shape for the
other six kinds: an ops table the backend fills in, and one entry point that takes
{kind, lifetimeId} rather than the object, because by the time the last SharedPtr has
dropped there is no object left to pass and the lifetime id is exactly what the client
slot allocator resolves a handle from. Declared only under MOBILEGL_PIPE_PUSH, so the
pull build's symbol set is untouched.
- BackendSlotTable::DestroyByLifetimeId drops the twin and returns the slot at the moment
the object goes, instead of at the next sweep - which for a renderbuffer or a texture
atlas is the difference between freeing the driver allocation now and freeing it 64
creations from now. It returns the slot only when THIS table holds it: two holders of one
kind already exist (the ScopedDirectGLESTextureBindings fixture; Magma's subsystem-4
table shares the VertexElementsCso kind), and a table that never twinned the object must
not free a slot the other one still names. The legacy arm keys on the frontend heap
address, cannot answer a notice at all, and keeps the sweep - which is the announced-
versus-discovered half of the A/B the compile-time arm exists for.
- Managers.cpp registers one dispatcher for all six kinds from ResolveEsprytSlotTablesArm(),
i.e. exactly when the arm that can answer a notice is the arm that runs, and drops a
notice that arrives after exit() has begun.
- FIRING it needs a destructor per class, and the P2 ownership table gives
{Texture,Framebuffer,Sampler,VertexArray}State/* to other packages, so only ProgramObject
and RenderbufferObject raise it here. The other four still rely on the sweep; their four
one-line calls retire it entirely.
- Three cases pin the three halves: the slot comes back with no sweep and the notice is
idempotent and does not free another holder's slot; a program and a renderbuffer announce
their own death when the last SharedPtr drops and not before; and the handle arm actually
installs a consumer, rather than the two halves each being fine on their own.