Commit Graph
100 Commits
Author SHA1 Message Date
swung0x48 12e6bfcf14 [Fix, Test] (Pipe): a make-current is not a teardown - the applier keeps its object records across one, counts every call refused on a record it does not have, advances the two vertex-input serials instead of restarting them at 0, and bounds the slot it grows a record table on 2026-09-08 03:51:21 -04:00
swung0x48 e6452ce948 [Fix] (Pipe): give both blob-carrying record families one Blob rule, and name the wire views the vertex-elements blob actually carries 2026-09-08 03:51:21 -04:00
swung0x48 56366331dc [Fix] (Pipe): account for DispatchIndirect and Query in Coverage.def's target-by-target split, re-sort the new emitted row, and name the P3a default mask in ConfigLoader's include comment 2026-09-08 03:51:21 -04:00
swung0x48 355c60b901 [Test] (Pipe): pin the resource record's lifecycle and the buffer sub-data range encoding at both of its bounds 2026-09-08 03:51:21 -04:00
swung0x48 6eb0e675ad [Feat] (Pipe): apply vertex elements, vertex buffers and the index buffer into the server's own working state 2026-09-08 03:51:21 -04:00
swung0x48 45c8f1a8be [Feat] (Pipe): apply the resource calls into a per-context slot-indexed record and dispatch them to the backend by handle 2026-09-08 03:51:21 -04:00
swung0x48 5cb826b01e [Merge] (dev): bring dev@9eae9858 into feat/disaggregated - the adopted-store respecify VAO rebind fix (d7655247) and the persistent-buffer ordering POST probe 2026-09-08 03:15:31 -04:00
swung0x48 e01c0ccc53 [Feat] (Pipe): land the P3a contract - the handle-shaped resource op table, the applier's resource and vertex-elements records, the two vertex wire views, an explicit baseInstance on set_vertex_buffers, and the first kNeedsAck 2026-09-08 02:43:19 -04:00
swung0x48 c036900d72 [Docs] (Disaggregated): add the Oppo Mali half of the P2 paired A/B - 16 rows per device, Espryt +8-18% and Magma +10-11% per-thread CPU over the pull baseline, plus the harness single-sample pidof kill recorded as a dev-side follow-up 2026-09-08 02:20:24 -04:00
swung0x48 44c2b5cf3a [Docs] (Disaggregated): record the P2 landing - the five-part gate results, the two-device paired A/B protocol with all 16 Xiaomi rows, DriverBench T1/T2 with the CSO control, the push-side counters, and the 2026-09-08 rule that performance is recorded against the pull baseline rather than gated 2026-09-08 01:40:51 -04:00
swung0x48 738b289df8 [Fix] (Magma, MG_IntegrationTest): drop the fourth ABA-control consumer, which was unreachable, and the scenario comment its own body contradicts
- 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.
2026-09-08 01:08:51 -04:00
swung0x48 a9778eaabe [Fix, Test] (Magma, MG_Test): cover the {slot, gen} generation in a unit test that forces a real slot reuse, and stop claiming the ABA lanes do
- 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).
2026-09-08 01:08:51 -04:00
swung0x48 c73ae7d443 [Test] (MG_IntegrationTest): pin MOBILEGL_PIPE_PUSH in the handle-arm lanes
- 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
2026-09-08 00:40:47 -04:00
swung0x48 55d2af9bd1 [Fix, Test] (Magma, MG_IntegrationTest): make the handle-ABA negative control construct its own collision and defeat the {slot, gen} generation
- 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).
2026-09-08 00:19:39 -04:00
swung0x48 2d690754dd [Tooling] (DeviceBench): verify the two campaign devices' pin profiles and add a pin helper that works on them
- 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
2026-09-07 23:43:11 -04:00
swung0x48 7a2e256133 [Build] (Android): let the trace APK be built in the push shape for the paired A/B
- -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
2026-09-07 23:38:49 -04:00
swung0x48 bb2a236d5f [Fix] (Pipe): drop the emitter arm for set_pixel_pack_state that spans retired
- 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
2026-09-07 23:38:10 -04:00
swung0x48 13d7e32b7b [Fix] (Tooling): restore the executable bit on the two device_bench entry points
- 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
2026-09-07 23:18:10 -04:00
swung0x48 b1c37699b1 [Fix] (Bench, Trace, CI): let only the profile answer for itself, name an unreadable profile, and describe the CI step by the mechanism the tree has
- 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.
2026-09-07 23:18:10 -04:00
swung0x48 e5603f9a46 [Fix] (Test, Pipe): probe every arm by content, and let the G7 control's exit status carry what it already knows
- 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.
2026-09-07 23:18:10 -04:00
swung0x48 08d14d85ef [Fix] (Bench): make the blend-toggle gate go red when the case it names stops running
- 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.
2026-09-07 23:18:10 -04:00
swung0x48 af20dba6db [Fix] (Trace, Bench, CI): compute p50 by the device's own median rule, fail the profile guard closed, and give the new control step its sibling's environment
- 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
2026-09-07 23:18:09 -04:00
swung0x48 a5d1136c02 [Fix] (Test, Pipe): ask the BUILD, not the source tree, whether a control's arm exists, and probe the CSO emitter by content
- 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
2026-09-07 23:18:09 -04:00
swung0x48 d704401a56 [Test] (Pipe): one case per CSO lane, because two of them would race on the lane's log
- 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).
2026-09-07 23:18:09 -04:00
swung0x48 ce9f44a24c [CI] (Pipe): make the dirty-surface report a gate and run the push-only unit tests on the verify runtime
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 1a012f2820 [Test] (Pipe): register the CSO control in the pull build too, so pull and push name the same tests
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 b9c137e146 [Feat] (Bench, Pipe): run the blend-toggle case in CI, give G7 a negative control, and record the two campaign devices
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 e9499d38bd [Feat] (Trace): record per-frame thread CPU time beside wall time so a paired A/B can be read as CPU cost
- 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).
2026-09-07 23:18:09 -04:00
swung0x48 f1780b9000 [Test] (Pipe): reproduce the handle ABA through public GL and pin the CSO content-addressing switch
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 e5c032c89e [Fix] (Magma): log the mint's high-water at a level a shipped build keeps
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 a174a06c79 [Fix] (Magma): size the {slot, gen} mint by the live working set instead of by a capacity, and give it to the renderer that uses 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.
2026-09-07 23:18:09 -04:00
swung0x48 46841ac706 [Fix] (Magma): assert rather than assume that a handle indexed into a per-slot table is non-null
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 01179c54d2 [Fix] (Magma): make the legacy-memo lever a startup gate for Magma's own bit, bound the {slot, gen} mint, and keep the all-pull arm free of push-only cost
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 3594f03c4e [Refactor] (State, Magma): take the backend's raw pointers out of the frontend VAO - the hash and state memos become the factory's own per-slot fields
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 43f8b47088 [Refactor] (Magma): key the vertex-input cache and the VAO draw memo on {slot, gen} instead of a lifetime id and a heap address
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 c74c4819fb [Refactor] (Magma): drive the dynamic tail from the pushed dynamic version and make the chunk table check DynamicTailKey's inventory
- 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).
2026-09-07 23:18:09 -04:00
swung0x48 96c544514e [Refactor] (Magma): key the pipeline memo on the render-state CSO handle and stop recomputing a hash the client already computed
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 59191cd296 [Fix] (Pipe, DirtySurface): resolve both sides of the dirty-surface derivation to member+field, follow reference and pointer aliases, and turn every write the analysis cannot place into an UNDECIDED answer instead of a verdict
- The writer side recorded a member-rooted write bound to a reference (`for (auto& blendState : m_parameters.BlendStates)`) as the field alone, so seven RenderState setters read as writing nothing; the reader side resolved `render.PatchVertices` to the whole of m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and a row saying glClearColor publishes the patch state was green. Both were the same defect: the two sides did not resolve to the same token.
- Both sides now carry MEM:<member> and FIELD:<member>.<leaf>; a whole-member write or read is every field. A reference, pointer or range-for alias bound to a member-rooted lvalue is followed (rebinds and aliases of aliases included), a write through a call-result lvalue and a mutating call on a member-rooted lvalue count as whole writes, a const alias cannot be written through with `.`.
- A write, or a non-read-only method call, whose root the analysis cannot place - a reference parameter, a call result, a member without the m_ prefix, an unattributable assignment operator - taints the function; the taint rides the call-graph fixed point and every (row, bit) that depends on a tainted function is UNDECIDED, never a verdict. Nothing is trusted by name.
- Match rule: a writer supports a bit iff the two sides share a member and, both field-resolved, their field sets intersect; a member in common with no field information on one side is COARSE, reported and never counted; UNDER-FIRING only when both sides are resolved and disjoint for every member the shutter reads.
- --check counts only supported answers as derived, prints the COARSE and UNDECIDED tallies, and fails on an UNDECIDED row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST in DirtySurface.def marks it; a mark on a row the derivation decides is a red gate too. The list is empty: all 8 non-render bit answers are supported at field level, and NEW_PATCH_STATE has exactly three legal carriers again.
- --self-test grows from 10 to 21 negative controls, including the synthetic bodies of every shape above through the real extractor, the NEW_PATCH_STATE analogue of the value-class control, the taint, COARSE and stale-mark paths, and positive controls for SetPixelStoreParam's pasted writes and the seven alias setters.
2026-09-07 23:18:09 -04:00
swung0x48 8d0ed5b82c [Fix] (Pipe, DirtySurface): read the writes that go through a member's field and the ones the preprocessor pastes together, and decline the rows the write analysis cannot answer - its "UNDER-FIRING" verdicts were an absence proof it did not have, and one of them put a false answer in the map for a bit P2 already ships
- 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
2026-09-07 23:18:09 -04:00
swung0x48 f15b0fdf4b [Fix] (Pipe): derive the dirty-surface map's object-class and value-class answers too, and correct the two rows that named a shutter their mutator never moves
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 8f66c374aa [Fix] (Pipe): re-arm the residual block on any render-state move and republish every vertex-attribute default on a fresh context
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 067b186677 [Docs] (Pipe): say what actually fails to publish a program, a pipeline and a shader dying, instead of naming a scope
- 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
2026-09-07 23:18:09 -04:00
swung0x48 5d4d91fe7e [Fix] (Pipe): carry the class a vertex-attribute default was written through, and stop the applier's lossy write from being observable
- 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
2026-09-07 23:18:09 -04:00
swung0x48 bb781df527 [Fix] (Pipe): count the render-state CSO binds the cache has always declared and never incremented
- 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
2026-09-07 23:18:09 -04:00
swung0x48 c574043c13 [Fix] (Pipe): derive the render-state answers of the dirty-surface map from RenderState.cpp instead of believing them, and correct the two rows that named a publisher which does not always fire
- 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
2026-09-07 23:18:09 -04:00
swung0x48 3ab394e2b8 [Test] (Pipe): give the pull build the same ctest names as the push build so a push-only case skips instead of vanishing
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 43bf97cc87 [Test] (Pipe): pin the tracker's shutters and the CSO cache's content addressing, including the collision the memcmp exists to stop
- 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".
2026-09-07 23:18:09 -04:00
swung0x48 3302ee82b5 [Feat] (Pipe): map every frontend mutator onto the aggregate generation that publishes it, and make the dirty-surface scanner a gate
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 7dec32a574 [Feat] (Pipe): carry what has no call of its own in the residual value block and abort when it disagrees with the assembled state
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 dcfa5ad311 [Feat] (Pipe): push pixel-pack, patch and vertex-attribute-default state as their own calls, the last one behind the set-hash suppressor
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 aa64c91052 [Feat] (Pipe): mint render-state CSOs on the pipeline subset and send only the dynamic chunks that moved - the steady-state cost of the whole render-state family is now two Uint16 compares
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 fcd4ad3799 [Feat] (Pipe): compute the per-verb dirty mask at the validate point and count how often each bit fires
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 de532f55a9 [Feat] (State): give the frontend six aggregate generations so a tracker can answer "did any bound texture, buffer, attachment or attribute move" with one Uint64 compare
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 3d1a866e82 [Test] (Espryt): pin that the armless cases put the operator's log path and MG_Config::Features back as they found them
- 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
2026-09-07 23:18:09 -04:00
swung0x48 df784c6752 [Fix, Test] (Espryt): deliver a death notice to every holder of a kind, retire the twin table's last sweep, and pin the bring-up call site under the armless knob pair
- 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)
2026-09-07 23:18:09 -04:00
swung0x48 c9dd173201 [Fix] (Espryt): close four review minors on the twin table - a walk that can outlive its vector, a null call that is not arm-equivalent, and two comments that claimed more than the code does
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 f5bd1a0412 [Test] (Espryt): drive the acquire / look up / delete / re-acquire walk through the real registry for all six re-keyed kinds
- 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).
2026-09-07 23:18:09 -04:00
swung0x48 6cb7d1b83b [Fix] (Espryt, State): let the last four object classes announce their own death and delete the twin table's garbage collector
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 7c97fcfee3 [Fix] (Espryt): stop the armless knob pair inside the test that needs an arm, not inside the EGL bring-up a forked pre-flight swallows
- 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).
2026-09-07 23:18:09 -04:00
swung0x48 caa0a7221b [Fix] (Espryt): pick the unit-bindings debounce by the runtime arm, and stop two slot cases sharing one kind
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 eb81705130 [Fix] (Espryt): tell the backend when a frontend object dies instead of discovering it in a garbage sweep
- 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.
2026-09-07 23:18:09 -04:00
swung0x48 e10f5d6750 [Test] (Espryt): run the sanity binary on the twin arm it was compiled for, and pin that it does
- SanityTest never calls MG_ConfigLoader::Init, so MG_Config::Features.PipePush kept its
  static default of 0 - the value a PULL build ships - and every case in the binary took
  StateBackendObjectRegistry's legacy UnorderedMap arm. In build-linux, build-push AND
  build-verify alike, the whole D13 "must not break" list (the scratch-FBO scrub, the three
  context-generation guards on the texture/framebuffer/renderbuffer twins, the sampled-set
  staleness walk and the whole-registry ScopedDirectGLESTextureBindings fixture) was
  therefore evidence about code this package did not change; a gdb breakpoint on
  MGPipeSlotAllocator::Acquire was the only way to see it.
- A gtest global Environment now seeds Features.PipePush with ConfigLoader's own push-build
  default, so the binary runs the arm that SHIPS in the build it was compiled for: legacy in
  build-linux (where the handle arm is not compiled and every DirectGLESSlotTable case skips
  visibly), handles in build-push and build-verify. MOBILEGL_PIPE_PUSH in the environment
  overrides it with ConfigLoader's decimal/0x contract, so the legacy-arm run of the same
  binary is one env var. It is an Environment and not a static initializer because
  MG_Config::Features has a String member and is dynamically initialised.
- TheTwinRegistryCasesInThisBinaryRunOnTheHandleArm is that gdb probe made falsifiable:
  delete the environment and it goes red naming the arm rather than a symptom.
2026-09-07 23:18:09 -04:00
swung0x48 5a3c0616b7 [Test] (Espryt): pin the churn-driven sweep cadence, the null tolerance, and the one slot per object the two Track H slices share
- ObjectChurnAloneDrivesTheSweep churns 256 objects through GetOrCreate and never calls
  CollectGarbage{IfNeeded,Now}: nothing but the creation tick can collect them. Verified
  red - it is the only case that fails - when the creation-driven sweep is neutered, and
  green again on restore. The churn count is a fixed constant rather than a multiple of the
  interval so that a negative control which pushes the interval out of reach makes the case
  FAIL instead of running for 2^32 iterations.
- TwoTablesOfTheSameKindAgreeOnOneObjectsHandle could not fail: HandleOf never reads the
  table, so any two tables agree for any implementation. Replaced by a case that asserts
  what is actually load-bearing - ONE slot of the kind is consumed however many tables hold
  a twin of the object (which is what makes Magma's subsystem-4 table resolve the same
  handle for the same VAO), and the shared handle still addresses each table's own twin.
- GetOrCreateToleratesANullStateObject pins the release-build behaviour the map arm had.
- The three names are registered as visible GTEST_SKIPs in the pull build, like the five
  before them, so the pull and push builds keep listing the same ctest entries.
2026-09-07 23:18:09 -04:00
swung0x48 9a8369296e [Fix] (Espryt): sweep the twin table on object churn again, refuse to run an arm the operator disabled, and give the hot lookups back their array probe
- The handle arm took only ONE of the registry's two sweep drivers. The map arm sweeps
  every 64 first-time insertions BECAUSE object churn, not draw count, is what makes the
  sweep urgent: a CTS-shaped case runs ~10 per-draw ticks, so the 1024-tick draw-path
  driver alone spans ~100 cases' worth of dead, gigabyte-sized twins. BackendSlotTable now
  carries the same kCreationGCInterval = 64 creation tick, swept before the entry reference
  exists for the same reason the map arm sweeps there. Without this the slice REGRESSED the
  memory it was supposed to leave unchanged.
- Fatal{PipeLegacyMemosDisabled} now aborts. It logged and then returned false, which fell
  straight into the legacy arm the operator had just made unreachable: a green run measured
  on the wrong arm, and the exact lever HandleRecycleScenario's arms are selected with. It
  is also resolved at backend context creation now, not on the first twin lookup, so a
  process that twins nothing still learns its knobs leave it with no arm at all.
- EsprytSlotTablesEnabled() becomes an inline latch over an out-of-line resolver. It is
  consulted on every Find/GetOrCreate/HandleOf/ForEachLive/CollectGarbage*, i.e. several
  times per draw, and as a cross-TU call with no LTO that was a PLT call per lookup.
- HandleOf keeps a one-entry lifetimeId -> handle memo, so the three per-draw resolution
  paths whose TwinLookupMemos this slice deleted go back to an integer compare plus an
  array index instead of the allocator's ByLifetimeId hash - which is the "direct slot
  indexing" the memo removal was traded for. It cannot serve a stale answer: a lifetime id
  is never handed out twice, and FindByHandle compares Gen anyway.
- GetOrCreate(nullptr) returns a parking slot instead of dereferencing null in a release
  build; the map arm inserted a null key and SyncTextureObjectToBackend documents relying
  on that tolerance.
- ReclaimDeadSlots moves the twin out before it writes the entry, so a twin destructor that
  re-entered GetOrCreate and grew m_slots could not make the writes land in freed memory.
- The framebuffer binding-slot cache is gated on kMGPipeSubsystemEsprytSlots rather than on
  the compile-time MOBILEGL_PIPE_PUSH, so MOBILEGL_PIPE_PUSH=0 stays the faithful
  all-subsystems-pull control ConfigLoader.cpp documents. The poison bypass stays closed on
  the arm that ships.
- MGB_TWIN_KIND_PARAM/ARG stop leaking into every TU that includes Managers.h: the twelve
  declaration and definition sites name a TwinRegistry alias template that swallows the
  kind in the pull build, and the one remaining macro is #undef'd after the class.
- The twin lookup inside BindCurrentFBO stops shadowing the framebuffer binding slot in a
  function whose whole subject is which "slot" is meant.
2026-09-07 23:18:09 -04:00
swung0x48 9eae98581f [Test] (SelfTest, POST): probe upload/draw ordering in persistently mapped vertex arenas - native GLES controls distinguish mapped destination corruption from staging and synchronization failures
Add the persistent buffer ordering probe to the GLES POST's known-driver-bug inventory. Queue updates and draws into independent FBOs before reading them back, covering SubData and copies from both coherent persistent and ordinary staging buffers. Retry fresh mapped allocations for intermittent corruption.

Require passing never-mapped and fully serialized controls before reporting a finding. Include finish-before, map-then-unmap and barrier diagnostics, preserve caller GL state, and treat setup, allocation and GL errors as inconclusive. This adds detection and reporting only; no rendering workaround is enabled.

Validation: all 46 DriverBugProbesTest tests pass, including 11 new ordering, control, collector and cleanup cases. The Android API 26 / NDK 27 native probe detects all three upload paths on Mali-G1-Ultra r54p1 with clean controls and no GL errors. llvmpipe reports no finding after 240 mapped FBO checks per upload path.
2026-09-07 00:44:08 -04:00
swung0x48 d7655247f7 [Fix, Test] (DirectGLES, Integration): rebind VAOs when an adopted buffer is respecified - the immediate retire path forgot the buffer-id generation, so cached vertex and element bindings kept the deleted store
Advance the buffer-id generation when Ops_Respecify retires immutable storage on the context thread, matching the existing adoption and deferred-retirement paths.

Add pixel regression coverage for unchanged VBO/IBO attachments across same-size redefinition, growth and shrinkage, including bound and unbound VAOs sharing a vertex arena and an index arena returning to shadow storage.
2026-09-06 22:55:06 -04:00
swung0x48 842af23331 [Chore] (Pipe): drop the executable bit from MGPipeRenderStateSpans.cpp
- A mode change on its own, so it does not ride inside a code commit. The other three of the
  four files created with 0755 were already corrected; this is the last one.
2026-09-06 09:51:14 -04:00
swung0x48 d1a7c5f159 [Fix] (Pipe): stop claiming set_pixel_pack_state supplies a field it only half writes, and fold the chunk boundaries into the subset hash's seed
- Coverage.def's emitted list named GetPixelStoreParameters, but the field is
  PipeInputs::m_pixelStore[2] - pack AND unpack - and set_pixel_pack_state carries the pack half
  only, deliberately and permanently. An emitted row is a licence for the residual fill loop to
  skip the field, so the moment the render-state bitmask has its bit set the unpack half would
  be written by nothing while its poison stamp said it was published, invisible to the poison
  and to the verify comparator alike. The row is gone and the reason is in the file; the pack
  half is simply written twice until the field is split.
- kMGPipeRenderStateChunkTableVersion was a promise nobody enforced: a boundary could move,
  the two byte-count assertions be updated, and every persisted key stay valid. The hash is now
  seeded with the version XOR a compile-time checksum of the boundary table, so a moved boundary
  invalidates the keys whether or not anyone remembered - and without a static_assert on the
  boundaries, which would turn G7's negative control into a build break instead of a red test.
2026-09-06 09:51:14 -04:00
swung0x48 ce370a3e84 [Test] (Pipe): drive both redundancy trip wires and the four apply entry points nothing in any build reached, and finish the setter walk's stencil faces and hint targets
- Five of the applier's eight entry points had no caller in any build, including both wires the
  redundancy of the patch trio and of the residual block exists to arm. ROADMAP.md asks that
  every gate be able to go red for the reason it exists; these could not change colour at all.
- Each wire is now driven in three states: DISARMED (the applier has not scattered the bytes it
  would compare, which is the MOBILEGL_PIPE_PUSH=0x10 shape), ARMED AND AGREEING, and ARMED AND
  DIVERGING. The diverging state is asserted in the form the build gives it - a poison or verify
  build aborts and the parent reads SIGABRT and the Fatal line out of the log, a shipped push
  build counts and logs - so neither case is skipped anywhere.
- ResidualTripWireHoldsAcrossAVerbClassThatDoesNotPublishTheBlock is the draw-then-dispatch
  sequence itself: it aborts on the pre-ledger form of the wire and passes on this one.
- The patch case carries a NaN outer level, a legal glPatchParameterfv value that must compare
  equal to itself, which is why the wire memcmps rather than compares.
- set_pixel_pack_state, set_vertex_attrib_defaults and delete_render_state are driven and read
  back through the accessor a backend would use, each under the verb class that publishes it.
  MGPipeDeriveRenderStateFields - D-7's kept whole-block form, which production does not call -
  is checked against the chunk-scoped one.
- The suite gains its own main() (and links gtest, not gtest_main) for PipeInputsTest's reason:
  the diverging cases read the wire's line back out of a log file this process names before
  anything logs.
- SetterConsistency drives SetStencilOp and SetStencilFunc and SetStencilMask on BOTH faces and
  SetHint on all four targets. Face 0's Func ends chunk P2 and its three ops open P3, which face
  1's Func closes, so the previous one-face-each walk never wrote P3 at all.
2026-09-06 09:51:14 -04:00
swung0x48 bee07c3273 [Fix] (Pipe): arm both applier trip wires off the applier's own scatter ledger instead of off a bound handle, and stop leaving a mixed CSO or a malformed attribute tail to a DEBUG-only assertion
- MGPipeApplierState gains ScatteredChunkBits: the global chunk bits this applier has itself
  scattered into PipeInputs::m_renderState, set by bind_render_state (the whole pipeline half)
  and set_dynamic_state (the chunks it names), cleared by MGPipeApplierReset. set_patch_state's
  own write is deliberately NOT in it - that is the other carrier, and a wire comparing against
  bytes it had just written would be a tautology.
- The residual trip wire now compares capability i only once every chunk that capability's
  answer is read out of is in the ledger. Both of the previous form's contracts were undeclared
  and one of them was wrong: with the render-state subsystem off (MOBILEGL_PIPE_PUSH=0x10 is a
  legal per-subsystem A/B, D14) the working block is the per-verb fill loop's, published per
  verb CLASS, and FillPoints.def does not publish GetRenderStateParameters at kDispatch or
  kTextureOp - so at a dispatch after a draw the block held the draw's bytes and a correct
  context could abort. With the ledger empty the wire now says nothing there, and with the
  subsystem on the applier is the block's only writer and its bytes are current at every class.
- The per-capability grain is not decoration: a bind alone owns the pipeline half, and the
  eight ClipDistances are answered from ClipDistanceEnabledMask in dynamic chunk D7, so between
  a bind and the first set_dynamic_state exactly those eight are unanswerable. The source
  chunks come from the same MGP_PLAIN_CAPABILITY_LIST and the same boundary table
  DeriveCapability reads, so the two cannot drift.
- The patch-carrier wire arms the same way, which replaces its "some CSO is bound" condition -
  a process-global that stayed set from the first bind onward - and covers the verb-class
  contract as well as the ordering one.
- Both wires now run in the shipped push build too, counting and logging where a poison or
  verify build aborts (one MGP_TRIP_WIRE_REPORT/TAG pair, so only the fatal arm writes the
  "Fatal{...}" marker G4 greps for). A wire compiled out of every build a device runs is not a
  wire, and the counters are what let a unit case see it fire in every build.
- create_render_state no longer leaves a dead BaseCso to MOBILEGL_ASSERT, which is inert at
  INFO - the level every gate and every shipped build uses. A recycled slot's record still
  holds the previous occupant's 396 bytes, so inheriting nothing and scattering the delta on
  top handed out a record that was half one CSO and half another. Both arms now start from a
  defined base and report. The same for a brand-new CSO that does not name every chunk.
- set_vertex_attrib_defaults walks the mask's 32 bits rather than the slot array, consumes a
  tail entry for every named location so a named-but-unstorable attribute cannot desynchronise
  the rest, and reports all three consistency faults in every build.
2026-09-06 09:51:14 -04:00
swung0x48 7c2c1456f8 [Test] (Pipe): make the slot allocator's ABA case prove its claim without waiting for the heap, and narrow the DEBUG skip to the arm that needs it
- LifetimeIdSurvivesARecycledAddress could not distinguish what its name claimed:
  Acquire never sees an address, and each round freed its handle, so the next handle
  differed whether or not the heap repeated the address - a restatement of
  GenMovesOnlyOnSlotReuse - and the case could silently GTEST_SKIP on a machine that
  never repeats one. The reuse count is now recorded rather than depended on, and a
  deterministic arm proves the strictly stronger form: re-acquiring the SAME LIFETIME
  ID after a free - the key the map is actually built on, which MG_State never
  reissues - still cannot reproduce the handle, because the reused slot carries a new
  generation. If that cannot reproduce a handle, no recycled address can.
- CompositeShaderBandIsNeverHandedOut skipped the whole case in a DEBUG build,
  including the two arms that trip no assert. The eight low-slot handouts and the
  "every other kind is unaffected" arm now run in every build; only the exhaustion
  walk, which trips the allocator's own "slot space is exhausted" MOBILEGL_ASSERT on
  purpose, is behind the skip.
2026-09-06 09:00:04 -04:00
swung0x48 ad1238bd6f [Test] (Pipe): require a named pipeline member to be WHOLLY pipeline, drive every indexed setter at index 0, and walk the incremental chunk path
- ChunkTablePartitionsTheBlock asserted only that a named pipeline member is TOUCHED
  by the pipeline half. A boundary that demotes part of one - four bytes at the head
  of BlendStates, i.e. the glEnablei(GL_BLEND, 0) bit - left the case green except
  for the two byte-count constants, which P3 will legitimately move; after that a
  partial demotion would have been invisible, and its consequence is one CSO handle
  serving two different pipeline states. Now IsWhollyPipeline for every named member,
  with StencilStates the one documented straddler.
- Every indexed setter is driven at index 0 as well as at a middle index. Index 0 is
  the element both backends consume and the one a head-of-array boundary demotes
  first; with it, the demotion above also fails SetterConsistency, naming the setter.
- The round trip D2 rests on is asserted: the assembled block is memcmp-equal to the
  live one. That is the only cover for the ~25 members with no derived field at all -
  SampleCoverage*, SampleMaskValue, PolygonModeBack, the hints, ScissorBoxes[1..15],
  ClipDistanceEnabledMask and the raw capability bools - which is exactly the set
  Espryt's SyncRenderState reads through its span memcmp.
- IncrementalChunksKeepEveryDerivedFieldInStep: the shape the tracker actually emits.
  A create_render_state naming only the pipeline chunks that moved against a BaseCso
  (D7 step 2's miss path) and a set_dynamic_state naming only the dynamic chunks that
  moved (D8's suppressor), ten steps plus all 35 capabilities one at a time, each
  followed by the full derived-field comparison. It is the first caller of the
  base-inherit branch, of MGPipeScatterPipelineChunks, MGPipePipelineChunkBlobBytes,
  MGPipe{Dynamic,Pipeline}ChunksThatMoved and MGPipeHashPipelineBytes, and it is the
  oracle for the applier's chunk-scoped derivation - a whole-block apply asks for
  every chunk and so cannot tell a correct guard from one that is too narrow.
- The 25 kDraw comparisons move into ExpectDerivedDrawFieldsMatch, shared by the
  whole-block walk and the incremental one.
2026-09-06 09:00:04 -04:00
swung0x48 a9bb99a46a [Fix] (Pipe): scope the derivation to the chunks a scatter moved, and give the patch trio and the residual block trip wires that do not depend on call order
- MGPipeDeriveRenderStateFieldsForChunks: the applier no longer recomputes all 29
  fields on every scatter. The four wide walks - the 8-wide blend/colour-mask loop,
  the 16-wide viewport loop, the 16-wide depth-range loop and the 35-arm capability
  switch - are guarded by the chunks whose bytes they read, so a per-frame glViewport
  (the D8 case that sends dynamic chunk D0 alone) pays for one 16-entry copy instead
  of ~170 stores and 35 switch dispatches. That cost sat on the per-draw path and the
  gate it threatened is G11's pinned ns/draw.
- Every guard is MGPipeRenderStateChunkBitsCovering(offsetof(member), sizeof(member))
  over the members the guarded block reads, computed from the boundary table: there
  is no second, hand-maintained member-to-chunk mapping to go stale when a boundary
  moves. The scalar copies stay unguarded on purpose - they cannot go stale, which
  keeps the risk of the scoping confined to four guards.
- MGP_PLAIN_CAPABILITY_LIST is written once and used twice, for DeriveCapability's
  switch arms and for the capability guard's chunk set, so the two cannot drift.
- set_patch_state now asserts under poison/verify that the trio agrees with what
  pipeline chunk P0 delivered, which D6 and D10 both ask for and which was missing:
  a stale set_patch_state silently clobbered the CSO-delivered levels. Compared
  bitwise, because a NaN outer level is legal and must equal itself; armed only once
  a CSO has been bound, which states the ordering contract rather than assuming it.
- set_residual_value_state's trip wire compares the carried bits against the WORKING
  BLOCK instead of PipeInputs::m_capability. Only the derivation writes m_capability,
  so a residual block emitted before the first bind of a context - what "once per
  context" means - compared against all-false storage while Dither and Multisample
  default to true, and aborted under poison. The check is unchanged in strength and
  now has no ordering contract at all.
- PipeApply.h no longer implies the verify comparator is this package's oracle: it
  arms off MG_Config::Features.PipeVerify, which a unit-test process never sets, so
  the unit oracle is named for what it is and the comparator is credited to the
  retrace and integration-verify lanes.
2026-09-06 09:00:04 -04:00
swung0x48 02b970e9c1 [Test] (Pipe): pin the slot allocator's identity contract - gen moves only on reuse and a recycled address never reproduces a handle
- Everything Track H keys off is only as sound as these statements, so each case is the
  answer to a bug the {slot, gen} pair exists to close rather than a coverage exercise.
- GenMovesOnlyOnSlotReuse: the first handout of a slot is generation 0; nothing in the
  interface can move a live handle's generation, which is "never on a respecify" stated as
  an absence; the bump lands on the NEXT handout rather than on the free, so a double free
  cannot skip a generation; and the stale handle then fails IsLive and cannot free the slot
  its successor owns. Kinds are independent slot spaces.
- FreedSlotComesBackBeforeHighWaterGrows: two freed slots are both handed back before a
  ninth is minted. Density is not a nicety - it is what lets the server's object table be
  an array indexed by slot rather than a hash map.
- SlotZeroIsNeverHandedOut, over every kind and across a free/allocate churn: {0, 0} is
  null for every kind and {0, 1} is the default framebuffer, so neither may be minted.
- LifetimeIdSurvivesARecycledAddress drives 64 construct/destroy rounds of a real
  VertexArrayObject through a volatile address sink (ObjectLifetimeIdTest's trick, so the
  new/delete pairs are not elided) and asserts that when the heap hands the same address
  back, the handle is still a different one. It also asserts Acquire is an identity - the
  same live object always answers the same handle - and that a freed lifetime id stops
  resolving. When the allocator refuses to repeat an address the case SKIPS with
  "inconclusive, not proven" rather than passing for the wrong reason.
- CompositeShaderBandIsNeverHandedOut walks the ShaderCso space to the composite base and
  asserts the last ordinary slot is base - 1 and that the next call REFUSES rather than
  stepping in. It skips in a DEBUG build, where reaching the edge trips the allocator's
  own exhaustion assert on purpose; the INFO builds the gates run are where it is checked.
- Replaces the contract commit's placeholder, whose one live claim survives as
  ReservedHandlesAreWhatMGPipeHandlesSaysTheyAre - the only case that is not push-only.
2026-09-06 08:16:53 -04:00
swung0x48 eec92cd221 [Test] (Pipe): walk every RenderState setter and assert the pipeline-subset hash moves exactly when the pipeline version does
- SetterConsistency is G7. It drives every public RenderState setter with a value that
  differs from the one stored, and asserts the pipeline-subset hash moves IF AND ONLY IF
  m_pipelineStateVersion moves. Every case also asserts m_version moved, which is the
  vacuity guard: a setter handed the value it already holds satisfies "neither moved"
  trivially and proves nothing.
- The cases that are not just a list: SetStencilFunc twice (a reference-only change must
  move the version and NOT the hash, because Ref/ValueMask are dynamic and Func is
  pipeline, and RenderState.cpp's pipeline bump is conditional on Func); SetPolygonMode
  with only the back face moving (PolygonModeBack is one of the members P2's subset added
  over the 24 ComputePipelineStateHash hashed); the eight ClipDistance capabilities (the
  one family that moves m_version alone, so the hash must hold); SetScissorBox across the
  ScissorBoxWrittenMask transition; all 25 SET_CAPABILITY names including the three that
  had no storage before the contract commit; and SetPixelStoreParam, which must move
  neither counter and touch no byte of the block.
- Verified red for the right reason: moving the P1/D2 boundary so ColorMasks falls in the
  dynamic half - the partition stays complete, so it still compiles - makes
  SetterConsistency fail naming SetColorMask and SetColorMaskIndexed, and nothing else.
- ChunkTablePartitionsTheBlock re-states the header's static_assert at run time and adds
  the half the hash cannot check for itself: membership. It walks PipeFields.def's
  MGP_FIELDS_RenderStateParameters - the same list gen_pipe.py checks against the struct -
  and asserts a member is covered by a pipeline chunk exactly when
  kMGPipePipelineStateMembers names it, that every other member is wholly dynamic, and
  that StencilStates straddles at exactly the sub-member granularity the table intends.
- DerivationMatchesTheFrontendGetters drives 30-odd setters on a live GLContext AFTER the
  filler has run, assembles the working block through the real create/bind/set_dynamic_state
  path, and compares the derived fields against the frontend getters. The stale fill is the
  point: an ASSERT_NE before each apply proves the block disagrees first, so nothing here
  can pass by comparing the filler with itself. It runs in THREE verb phases, because the
  fill table is the only thing that says what a verb may read: 25 of these fields are
  kDraw's, the three clear values are kClear's and GetClampReadColor is kReadback's alone,
  and reading a clear value under DrawArrays is Fatal{UnmigratedPipeInput} - correctly, and
  the poison caught exactly that in the verify build before this shape.
- GetViewport's rounding is exercised on (1.5, 2.5, 63.5, 32.25) and the expected literal is
  std::lround's answer - half away from zero - not the banker's rounding nearbyint gives.
- DynamicChunksCoverMagmasDynamicTailKey checks every GL-state input of DirectVulkan's
  ApplyDynamicDrawStateTail against the dynamic half, and records the one exception the
  design implies but no document states: ScissorTestEnabledMask is read by DynamicTailKey's
  scissorEnabled yet is PIPELINE state, because SetCapability(ScissorTest) calls
  BumpVersions(). Harmless - BumpVersions moves m_version too, so MGPDynamicState::Version
  still moves and the tail still re-runs - and asserted the other way round so a later
  table edit that demotes the mask is loud here.
- Replaces the contract commit's placeholder case, which existed only so the target had a
  test before this package filled it in. The four names are the same four in every build:
  in a pull build each is a visible SKIP, never a vanishing test.
2026-09-06 08:16:53 -04:00
swung0x48 810850b13a [Feat] (Pipe): derive every render-state PipeInputs field from the assembled working block instead of pulling it again from GLContext
- 29 of the 47 kDraw PipeInputs fields are pure functions of RenderStateParameters. Once
  bind_render_state and set_dynamic_state have assembled the working block - which IS
  PipeInputs::m_renderState - copying those fields out of GLContext a second time is the
  per-verb pull P2 exists to remove. The applier now derives them after any scatter.
- Each line is a transcription of the RenderState getter of the same name; GLContext's
  accessors are one-line forwards to those, so the derivation and the pull path answer the
  same question from the same bytes. Two are not field copies and are transcribed exactly:
  GetViewport (viewport 0 rounded with std::lround, because glGetIntegerv on float state
  rounds to nearest and truncating a 63.5-wide viewport would hand the backends a rectangle
  one pixel short), and IsCapabilityEnabled (the 35-way switch, including Blend ->
  BlendStates[0].Enabled, ScissorTest -> ScissorTestEnabledMask & 1 and the ClipDistance
  run). The indexed twin is the same for Blend[8] and ScissorTest[16].
- IsCapabilityEnabled could not have been written before the contract commit: DepthClamp,
  FramebufferSrgb and TextureCubeMapSeamless fell to `default: return false`, so three of
  the 35 answers were a compile-time constant rather than state.
- This departs from P1 brief D4's "no derivation logic is re-implemented in PipeInputs",
  deliberately: the alternative is to keep pulling those 29 fields per verb. The guard is
  the oracle P1 built - MOBILEGL_PIPE_VERIFY's compare-at-read re-reads every one of them
  from the live context at EVERY backend read and compares field-wise, so a transcription
  error is caught on the first draw that reads it, across 79 retraces and the
  integration-verify entries.
- The body sits in MGPipeApplyAccess, the struct PipeInputs already names as its friend, so
  no new friend and no new accessor per field. Pull build untouched: PipeApply.cpp compiles
  only under MOBILEGL_PIPE_PUSH.
2026-09-06 07:58:53 -04:00
swung0x48 9c6a8a25d8 [Feat] (Pipe): land the P2 contract - real storage for the three swallowed capabilities, the render-state chunk table and its subset hash, the in-process applier, the slot allocator, the subsystem bitmask and the residual ratchet down to 8
- FramebufferSrgb, DepthClamp and TextureCubeMapSeamless get real storage. All three fell
  to SetCapability's "not supported currently" arm and IsCapabilityEnabled's default:
  glEnable was swallowed and glIsEnabled lied, so DirectGLES' sRGB block and the
  DirectVulkan read points consumed a constant. The three Bools land in the three
  alignment bytes at [581, 584) between ColorMasks and ClearColor, so
  sizeof(RenderStateParameters) stays 1168 and NO existing offset moves - Espryt's
  kBlendSpanBegin/kBlendSpanEnd (312/536) and the whole chunk table depend on that.
- MGPipeRenderStateSpans.{h,cpp}: the pipeline/dynamic split, written in exactly one place.
  The rule is the only rule - a byte is pipeline state iff a public RenderState setter that
  calls BumpVersions() writes it - which makes G7's "the subset hash moves iff
  m_pipelineStateVersion moves" true by construction. 16 boundaries, all offsetof or
  sizeof, alternating dynamic/pipeline: 8 dynamic chunks / 772 bytes and 7 pipeline chunks
  / 396 bytes, partitioning [0, 1168) exactly, asserted at compile time.
  MGPipeComputePipelineSubsetHash is XXH64 over the seven pipeline chunks, seeded with a
  table version so a chunk-table change invalidates every persisted key.
- The pipeline subset is now a strict SUPERSET of the 24 members ComputePipelineStateHash
  hashed: 44 members, adding sample coverage, the front face, the provoking vertex, the
  scissor-test mask, the back polygon mode, eleven capability bools the hash never read and
  the three above. Demoting those setters to ++m_version instead would have changed
  MG_State semantics in the PULL build for the push path's sake. The hash runs only when
  m_pipelineStateVersion moves, which is exactly when Magma re-hashed before.
- PipeApply.{h,cpp}: the in-process applier, the server half of the P2 calls. The server's
  working RenderStateParameters IS PipeInputs::m_renderState, which is why DirectGLES'
  SyncRenderState is not one line changed and why the verify comparator stops being a
  tautology. Per-context CSO store indexed by slot, gen-validated; the residual block's
  capability bits are compared against the assembled block, so a capability a later call
  takes over and forgets to carry is Fatal{PipeResidualDiverged}.
  MGPipeDeriveRenderStateFields is a declared STUB - its 29 derivations are commit c1.
- SlotAllocator.{h,cpp}: the client's per-kind {slot, gen} allocator, free list plus
  high-water, first allocatable slot 1, gen bumping only on slot REUSE, a debug assert on
  gen wrap, the composite ShaderCso band held back, and a lifetimeId -> slot map per kind so
  a GL name never enters a key. In the contract because both Track H slices need it.
- ResidualValueBlock 1248 -> 8 bytes, one Uint64 of capability bits.
  RenderStateParameters retired to create/bind_render_state and set_dynamic_state, Pack to
  set_pixel_pack_state, the patch quintet to set_patch_state. gen_pipe.py now emits the
  member-by-member offsetof assertions the ratchet comment always promised.
- gen_pipe.py: PIPELINE_STATE_MEMBERS grows to the 44-member set in declaration order and
  PipeSpanTable.inc's "deliberately absent" block records the answers instead of the
  questions; Coverage.def gains MGP_COVERAGE_EMITTED_LIST (34 rows) and PipeFilled.inc
  gains kMGPipeFieldEmittedBy[], which is what lets the residual fill loop skip a field a
  P2 call now supplies. One more --self-test negative control covers the new list.
- MOBILEGL_PIPE_PUSH becomes a per-subsystem bitmask with named bits (0..6 migrated at P2,
  bit 63 the CSO-content-addressing negative control), defaulting to 0x7f in a push build
  and staying 0 in a pull build. New CMake option MOBILEGL_PIPE_LEGACY_MEMOS, ON, forced ON
  when MOBILEGL_PIPE_PUSH=OFF where it is the only arm. New Features.PipeHandleAbaControl
  under MOBILEGL_PIPE_PUSH, negative control C for HandleRecycleScenario.
- PipeStats gains CallClass::{RenderStateCsoMints, RenderStateCsoBinds} (csom / csob on the
  summary line), and they are PUSH-ONLY: growing the enum in the pull build would resize
  the counter arrays, the name table and FormatWindowLine for two counters that could never
  leave zero, and G1 admits no such resize.
- Four MG_Test/Pipe stubs plus their CMake registration, so the packages that own their
  contents never touch MG_Test/Pipe/CMakeLists.txt.

G1, pull build, symbol_report --threshold 0: 0 added, 0 removed, 0 renamed, 4 resized, and
every resize is attributed:
  RenderState::RenderState()                       1700 -> 1848 (+148)  the three {}
  RenderState::SetCapability(CapabilityInput,bool)   850 ->  927  (+77)  three switch arms
  RenderState::IsCapabilityEnabled(CapabilityInput)  239 ->  268  (+29)  three switch arms
  _GLOBAL__sub_I_DirectGLES.cpp                     1340 -> 1331   (-9)  the static
    initialiser of DirectGLES.cpp's `static RenderStateParameters
    g_syncedRenderStateParameters` re-scheduling around the three new default-initialised
    members. A shrink, and the only unforeseen entry; it is a direct consequence of the
    struct gaining members and touches no interface.
2026-09-06 07:45:07 -04:00
swung0x48 4826806881 [CI] (Workflows): run the test and apk lanes on every push to feat/disaggregated
- the MGPipe phases land as a series of pushes and each one needs the full lane; dispatching
  by hand after every merge is a step that gets forgotten
- both entries carry a remove-before-merging-to-dev note: dev's trigger set is what ships
2026-09-06 06:32:37 -04:00
swung0x48 e7a6a72f6a [Docs] (Disaggregated): record the P1 landing and what its verify lane found
- README status, the ROADMAP P1 row with the measured site and accessor counts, and the
  ARCHITECTURE correction from 293 to the 277 arrow sites the tree actually has
- MEASUREMENTS gains the P1 scale table, the two classes of finding the verify lane
  produced (nine missing fill rows, three fields a backend moves inside its own verb) with
  the push-on-mutation decision and its three hooks, and the acceptance numbers
2026-09-06 06:17:12 -04:00
swung0x48 62a7786184 [Fix] (Pipe, Purity): end a declared verb honestly, and gate the header MG_State now includes
- MGPipeLeaveVerb() bumps the serial and puts the current verb back to none, so a test that
  drives a backend helper directly stops declaring where it says it stops and a later
  unguarded read aborts as "<Field>@<none>" instead of naming an unrelated verb
- check_include_closure.py gains a fourth probe: F2 put MGP_NOTE_MUTATION into frontend
  mutators, so MG_State includes MG_Pipe/PipeMutation.h and that header must never reach
  back into MG_State, MG_Impl or a backend
2026-09-06 06:15:56 -04:00
swung0x48 ef6227e19b [Test] (Pipe): let a test that drives a backend helper directly declare the verb it stands in
- ScopedPipeVerb (MG_Test/ScopedPipeVerb.h): an RAII "as if we were inside verb X" object
  that runs the real MGPipeFillForVerb for the verb it names, so the eleven unit entries
  that construct a GLContext by hand and call a backend helper with no GL entry point in
  between stop reading an empty, unstamped PipeInputs block
- it weakens nothing: it fills exactly that verb class's may-read mask, so a read outside
  it is still Fatal{UnmigratedPipeInput} naming the field and the declared verb; leaving
  the scope re-arms the poison with a one-field kQuery fill, so one case's declaration
  cannot cover a later one when the binary runs as a single process
- placed the way MGP_FILL is placed in production: immediately before the backend call,
  after every frontend mutation it is meant to see; a second call after the test moved
  state is a second verb (Renew()), a helper of another class gets a nested scope
- no-op in the pull build, no test renamed, no test added, no production source touched
2026-09-06 06:01:41 -04:00
swung0x48 9bd6d39403 [Test] (Pipe): pin the push-on-mutation shape - a frontend write inside a verb refreshes the pushed field, and only its value
- AFrontendMutationInsideAVerbRefreshesThePushedField: fills DrawArrays, then
  does what UniformManager's fallback path does mid-draw (a SamplerObject filter
  change) and what a bind reached from inside a verb does
  (NoteTextureUnitTouched), and asserts the block still equals the live context
  for GetSamplingResolutionGeneration, GetTextureBindGeneration and
  GetMaxTouchedTextureUnit.
- AFrontendMutationInsideAVerbDoesNotDivergeAtRead: the lane failure end to end,
  under the armed comparator in a forked child - the read after the mutation
  must complete and the log must carry no Fatal{.
- TheMutationNoticeRefreshesTheValueButNotTheStamp: the notice must not restamp
  a field whose stamp the fill withheld (negative control B), and must not stamp
  a field the verb class never fills (the generation under a kQuery verb).
- Falsified: with the notice's body short-circuited, the first two fail (the
  read-side one by SIGABRT on Fatal{PipeVerifyDiffer,
  "GetSamplingResolutionGeneration@DrawArrays", where=read}) and the third
  stays green, which is what a guard case should do.
- The three names also exist as visible GTEST_SKIPs in the pull build, as every
  other case in this file does.
2026-09-06 05:32:25 -04:00
swung0x48 6b681c4a63 [Fix] (Pipe, State): refresh a pushed PipeInputs field when the frontend moves it inside a verb
- The verify lane aborted eight integration entries and two retrace cases with
  Fatal{PipeVerifyDiffer, "GetSamplingResolutionGeneration@DrawArrays",
  where=read}, always one line after "ResolveSamplerDescriptor: using fallback
  texture for unbound sampler". The backends write into frontend objects during
  their own verb - Magma synthesises a fallback texture for an unbound sampler
  and gives it a shape, materialises a queued clear, overrides a unit's sampler
  filter - and every one of those writes moves a counter MGP_FILL already
  copied, so the pushed block stops equalling the live context for the rest of
  the verb. That is a real divergence, not a harness artefact: the pull build
  reads the moved value and the push build reads the boundary one.
- Takes the findings' preferred option, push on mutation, over the volatile-in-
  verb class: it keeps the comparator's invariant ("the pushed block equals the
  live context at every read") literally true, keeps push semantics equal to
  pull, and is the shape P2's tracker needs. The fallback would have had to skip
  compare-at-read for the field, which is the one comparator arm that is real in
  P1 - it would have blinded the gate on the very field that found the bug.
- MG_Pipe/PipeMutation.h declares MGP_NOTE_MUTATION(Field), a no-op that
  includes nothing in the pull build; MG_Impl/Pipe/PipeFill.cpp defines the
  notice next to the filler it shares CopyField with. The notice refreshes one
  field's value when a context is live, a verb has been filled, and the field is
  in that verb class's may-read mask; it never touches the poison stamp, so a
  stamp MOBILEGL_PIPE_POISON_OMIT withheld stays withheld and a field the verb
  never filled stays Fatal{UnmigratedPipeInput} rather than being healed.
- The enumeration behind the three hook sites: of the ~40 backend->frontend
  write sites, only the texture family reaches a pushed value. Every path
  through them funnels into TextureState::BumpSamplingResolutionGeneration
  (SamplerObject::BumpVersion for the sampler setters,
  TextureObjectBase::BumpShapeVersion for AllocateStorage / SetInternalFormat /
  TruncateMipmapLevels / SetSamples / SetFixedSampleLocations),
  BumpTextureBindGeneration (a default texture becoming defined, delete-unbind,
  a unit's sampler object changing) or NoteUnitTouched (which also moves the
  touched-unit high-water mark), so the notice sits on the counters rather than
  on each writer and covers the whole family including writers added later.
  The buffer, program and VAO writes reach no pushed field: their objects are
  read back through O-class live references, not copied values.
2026-09-06 05:32:13 -04:00
swung0x48 80a6b39003 [Fix] (Pipe): fill the capability set on a texture op and a dispatch, and the shader blit's viewport, provoking vertex and buffer bindings
- The verify retrace aborted four cases with Fatal{UnmigratedPipeInput,
  "IsCapabilityEnabled@GenerateMipmap"} (x3) and "@DispatchCompute" (x1):
  Magma materialises a texture's queued clear inside both verbs
  (GenerateMipmap -> MaterializePendingClearForTexture,
  DispatchCompute -> PrepareStorageImageTextures -> the same), and the clear
  pre-compensates its colour against GL_FRAMEBUFFER_SRGB in
  VkClearManager::PreCompensateSrgbClearColor. Neither class named the field.
- Audited every class the same way rather than stopping at those two rows. Two
  more helper-program draws sit inside verbs whose class did not name what they
  read: GenerateMipmap takes GenerateDepthMipmapWithShader for a depth texture
  and BlitFramebuffer takes TryBlitToDefaultFramebufferWithShader for the
  default framebuffer. Both bind their helper's descriptors through
  BindProgramUniformBuffers, whose sampler resolver reads the draw framebuffer
  for its feedback-loop check and whose buffer-block resolvers read the frontend
  binding points; the blit additionally sets the dynamic viewport through
  ApplyGLViewportState -> ComputeGLViewport and picks its pipeline's provoking
  vertex through GetOrCreateBlitPipeline -> SelectProvokingVertexMode.
- kTextureOp gains IsCapabilityEnabled, GetFramebufferBindingSlot and
  GetBufferBindingPoint; kDispatch gains IsCapabilityEnabled; kBlitOrCopy gains
  GetViewportIndexed, GetDepthRangeIndexed, GetProvokingVertexMode and
  GetBufferBindingPoint. Every row carries the path it was derived from.
- Also unfolds the kReadback transform-feedback rows 9087f133 landed on one
  1200-column line back into the file's one-row-per-line shape; no row changes.
2026-09-06 05:31:50 -04:00
swung0x48 97b997d5da [CI] (Pipe): read the verify library's static symtab, grep only the arming lane's log, and stop the two retrace lanes overwriting each other's evidence
- both `nm -D --defined-only ... | grep -q MGPipe...` gates could never pass: the library
  is built CXX_VISIBILITY_PRESET hidden in every non-Debug configuration and the MGPipe
  entry points carry no export attribute, so the dynamic table holds none of them (0 of
  11930 exported symbols on this tree, while `nm --defined-only` finds MGPipeFillForVerb
  as a local `t`). build-linux-verify, and with it integration-verify and every
  retrace-verify entry, would have been red forever for a reason unrelated to the
  comparator. Both now read the static table, name what a miss means, and refuse a
  stripped artifact instead of reporting its silence as a missing symbol
- "Every verify process really armed" grepped one shared per-lane log that holds only the
  LAST process of 406 - and the last ambient entry is the poison control's parent, which
  forks, execve()s and waits without ever issuing a verb, so the step would have red a
  healthy lane while proving nothing about the other 405. It now greps the two
  VerifyArming. lanes' own logs, requires one per backend, and says what that establishes
- remove-artifact-clutter kept fixtures for failed `retrace (` jobs only, so a failed
  `retrace verify (` case lost the fixture needed to reproduce it; both prefixes now count
- the retrace negative control replayed OpenRA into the same case directory, so
  "Upload actual image" shipped the deliberately corrupted run's output under the good
  run's name. The verified output is put aside and restored before the verdict
- build-linux-verify regains build-linux's "Show installed toolchain" step, and its
  deliberate divergence (Release even under ACTIONS_STEP_DEBUG - a Debug build would flip
  visibility and arm the poison through a different #if arm) is written down
- the lane's scope is stated where it is run: every integration ENTRY under the
  comparator, not every configuration - `integration`'s second
  MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 pass (186 entries here) is not affordable at
  the 5-10x the comparator costs, so that tier stays covered unverified by `integration`
2026-09-06 04:41:28 -04:00
swung0x48 7d80c9678e [Test] (Retrace): scan a verify retrace's log for the third MGPipe Fatal too
- the block looked for Fatal{PipeVerifyDiffer and Fatal{UnmigratedPipeInput only. A
  misspelt MOBILEGL_PIPE_VERIFY_CORRUPT / MOBILEGL_PIPE_POISON_OMIT reports
  Fatal{PipeVerifyBadKnob, and it was caught only because D2 makes that one abort the
  process - which is precisely what MOBILEGL_PIPE_VERIFY_FATAL=0, the supported triage
  configuration, takes away. A typo'd knob would have left a negative-control run
  looking healthy
- the FATAL_ERROR text now says what each of the three means and where the two
  vocabularies live, because that message is the whole diagnosis a `cmake -P` step gets
2026-09-06 04:41:28 -04:00
swung0x48 72aa9191b4 [Fix] (Tooling): gate symbol_report on all four buckets and on a .text that moved in either direction
- G1 is spelled "added == removed == resized == renamed == 0, .text delta 0", but
  gate_failures() took only added and removed and fired on text_delta > N: a pull build
  whose .text SHRANK, or whose functions were resized with zero net delta - the exact
  shape a null-guard or ternary rewrite produces - walked through
  `--threshold 0 --fail-on-symbol-set-change --fail-on-added-bytes 0` green
- --fail-on-symbol-set-change now covers the four buckets the report prints, and
  --fail-on-added-bytes 0 means byte-identical rather than "did not grow" (a positive
  budget keeps the one-sided meaning). --fail-on-text-delta is the explicit spelling for
  a run that wants no byte budget at all
- the gates read the threshold-0 buckets whatever --threshold says: --threshold is a
  report control, and a gate that read the thresholded resize list would have quietly
  weakened itself the day someone raised it. The run says so in its output
- the self-test drives the three shapes that used to pass (a shrunk .text at budget 0, a
  resize and a rename at zero net delta) from the same canned transcripts
2026-09-06 04:41:28 -04:00
swung0x48 1e3a74686f [Test] (Pipe): give the arming assertion a lane and a log of its own, and stop the poison child calling a sequence it never ran a success
- the arming case read the ambient lane's MOBILEGL_LOG_FILE_PATH, and that log is
  opened fopen(path, "w") by every process in the lane: with 406 entries per backend
  and CI running them -j 4, a whole-file read races a neighbour's bring-up, and the
  file that survives the lane holds only the LAST writer. Every other log-reading
  scenario in this suite (UnlocatedIoBlocks, the primgen reroute, the point-size
  demotion) is registered in a filtered lane with its own log for exactly that reason;
  PipeVerifyArmingScenario.Armed now follows them, in DirectGLES.VerifyArming. /
  DirectVulkan.VerifyArming., and skips anywhere MGITEST_PIPE_ARMING_LANE is unset
- what that can prove is written down where it is asserted: arming is a property of
  (this library, this environment) and these two processes share both with their ~400
  ambient siblings. A per-process census is not available through a shared log, and a
  comment that claimed one was the reason CI grepped a file that could not answer
- the re-exec'd poison child ran RunSequence() and then _exit(0) unconditionally, so a
  fatal assertion inside it - the shader failing to compile, say - returned before the
  draw and the glGenerateMipmap and still reported success: WithoutOmissionCompletes,
  the one green entry negative control B turns red, passed on a child that ran none of
  the sequence. It now exits HasFailure() ? 1 : 0, and checks glGetError() after the
  mipmap so a rejected sequence is part of the answer rather than stderr nobody reads
2026-09-06 04:41:28 -04:00
swung0x48 0fe7bf82d2 [CI] (Pipe): the third CI mode - a verify build, its integration and retrace lanes, and the two negative controls as always-on steps
- build-linux-verify is a second Release/INFO build with -DMOBILEGL_PIPE_VERIFY=ON,
  because the comparator is a compile-time option and does not exist in the shipped
  library. It refuses to ship an artifact whose libMobileGL.so does not export
  MGPipeVerifyInputs and MGPipeFillForVerb: a typo'd -D is not an error in CMake,
  and every lane below would then be green having compared nothing.
- integration-verify runs the suite with the comparator armed and then proves it
  armed twice over: --no-tests=error reds a build whose verify entries were never
  registered, and a step greps every pipe-verify-*.log for the arming line.
- The two negative controls are steps of that job, not a manual exercise: a gate
  that can only be shown to work by someone remembering to break it has already
  stopped working. Each passes when ctest FAILS, and each first counts its own
  selection - an empty selection also exits non-zero under --no-tests=error, and a
  control that passed because it ran nothing would be worse than no control.
- Control B targets PoisonOmissionScenario.WithoutOmissionCompletes, the only
  integration entry in the tree that calls glGenerateMipmap at all; the case no
  longer skips itself when the omission knob is set, precisely so that the control
  has a green entry to turn red.
- retrace-verify replays the eight "verify": true cases against the verify library,
  copied over build-linux/libMobileGL.so because build-retrace freezes that absolute
  path into every case, with an nm check that the swap happened and an inverted
  OpenRA step that must go red under MOBILEGL_PIPE_VERIFY_CORRUPT.
  remove-artifact-clutter now waits for it: it deletes the trace fixtures these jobs
  download.
- monolith-symbol-report is G1 as a job: two pull builds with identical flags and
  LTO off, the baseline named by a workflow_dispatch input, symbol_report.py with
  both hard gates, and a refusal of any MG_Remote symbol in the monolith. It is
  dispatch-only because its answer is about a baseline, not about this push.
- pipe-gates gains the two --self-test steps. Regenerating and diffing cannot see a
  structural check that silently stopped checking; a broken gate and a clean tree
  produce the same green. Its dirty-surface comment now says P2, which is where
  ROADMAP.md:18 puts the first mapping round.
2026-09-06 04:41:28 -04:00
swung0x48 416cd23c28 [Feat] (Tooling): give symbol_report.py the two hard gates G1 needs, with the report written first
- --fail-on-added-bytes was a reserved no-op that printed "this run stays
  informational"; it now exits non-zero when .text grew past the budget, and
  --fail-on-symbol-set-change joins it for the added/removed buckets. Together they
  are the spelling of P1's G1 ("the pull build is byte-identical"): --threshold 0
  --fail-on-symbol-set-change --fail-on-added-bytes 0.
- Default behaviour is unchanged: with no gate flag the tool prints its report and
  exits 0, which is what every existing caller and the informational
  monolith-symbol-report job expect.
- A gate fires AFTER the Markdown and JSON are written, never before: the report is
  the diagnosis, and a CI job that failed before uploading its artifact is one
  nobody can act on.
- The decision lives in a pure gate_failures(), so --self-test drives it from the
  same two canned transcripts as the buckets: each flag fires on the canned
  add/remove/+100 delta, each stays quiet when it was not asked for, and a
  tolerated budget is tolerated. A gate whose only test is a real build is a gate
  nobody re-tests.
2026-09-06 04:41:28 -04:00
swung0x48 5f8e8db1b9 [Test] (Retrace): make a retrace under MOBILEGL_PIPE_VERIFY prove it armed, and give the lane a label
- A retrace that exports MOBILEGL_PIPE_VERIFY=1 at a library configured without
  -DMOBILEGL_PIPE_VERIFY=ON is a no-op: the frames still match their goldens and the
  case reports green having compared nothing. run_trace_case.cmake now demands the
  evidence whenever the variable is set to anything but 0/false - mobilegl.log must
  exist, must carry "MGPipe: verify armed", and must carry neither
  Fatal{PipeVerifyDiffer nor Fatal{UnmigratedPipeInput. With the variable unset the
  script is byte-for-byte the old one.
- The Fatal scan is not redundant with the replay's exit status:
  MOBILEGL_PIPE_VERIFY_FATAL=0 is the supported triage configuration, and there a
  divergence is logged and counted rather than aborted, so the run would finish 0
  with its own report sitting unread in the log.
- LABELS retrace on both registrations: the lane was selectable only by regex, so
  `ctest -L retrace --no-tests=error` - the spelling that reds a lane which
  registered nothing - could not be written at all.
- "verify": true on eight cases (the five 180s cases plus minecraft-1.21.4-in-world,
  minecraft-1.21.4-fabric-sodium-in-world and improved-transparency-minecraft-26.3),
  and --format github-verify-matrix over that subset: 16 entries, against the full
  lane's 77. The verify build compares at every verb boundary and again at every
  accessor read, which the design budgets at 5-10x, so the per-push job runs the
  subset and the full sweep is a phase-exit / workflow_dispatch run.
- The flag is validated in the manifest loader, not at the matrix, so a non-boolean
  or a verify case excluded from CI is a loud error in every consumer instead of a
  subset that is quietly one case short.
2026-09-06 04:41:28 -04:00
swung0x48 bdf05514c3 [Test] (Pipe): the integration-verify lanes and their two always-on negative controls
- ARCHITECTURE.md 13.2-(2) asks for a third CI mode, and a third mode whose only
  evidence is "ctest was green" proves nothing: MOBILEGL_PIPE_VERIFY=1 against a
  library that never compiled the comparator in is a silent no-op that looks
  exactly like a clean pass. Six registrations, all under if (MOBILEGL_PIPE_VERIFY)
  and all labelled integration-verify, make both halves falsifiable - a
  mis-configured build registers nothing and --no-tests=error reds the lane, and
  PipeVerifyArmingScenario.Armed fails a lane whose library never printed its
  arming line.
- PipeVerifyArmingScenario.CorruptedFieldIsReported is negative control A (G4):
  its lane pins MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters with
  MOBILEGL_PIPE_VERIFY_FATAL=0 so the process survives its own divergence and can
  read the report back; the CI step that exports the same knob against the ambient
  lane, where FATAL keeps its default, asserts the other half - the abort.
- PoisonOmissionScenario is negative control B (G5), in two cases that cannot share
  a process because the knob is process-wide: the omitted (verb, field) pair must
  abort the glGenerateMipmap and NOT the draw before it, and the same sequence with
  the knob unset must complete with no Fatal at all.
- The sequence runs in a fork()+execve() of this same binary rather than a bare
  fork(): the fixture has already brought a context up, and a bare fork of a
  process holding a live Vulkan device inherits the driver's mutexes with no
  threads to release them - measured here as a 120s wedge on DirectVulkan against a
  clean pass on DirectGLES. The child gets its own MOBILEGL_LOG_FILE_PATH because
  the library opens its log with fopen(path, "w") and would otherwise truncate the
  file the parent is about to read.
- No ambient Verify. entry names MOBILEGL_PIPE_VERIFY_CORRUPT or
  MOBILEGL_PIPE_POISON_OMIT in its ENVIRONMENT property, because a property entry
  overrides the job environment for the names it lists: the two CI negative-control
  steps export those knobs into the job environment and must reach the processes.
  Every list appends MGL_ITEST_COMMON_ENV / MGL_ITEST_VULKAN_ENV for the same
  reason, so the vendor and ICD pinning survives.
2026-09-06 04:41:28 -04:00
swung0x48 bf8b39a867 [Refactor] (Magma): route every frontend read through MGB_CTX - 164 arrow sites sed'd, 49 non-arrow lines converted (43 asserts keep their meaning as MGB_CTX_LIVE); pull build byte-identical
- Every MG_State::pGLContext-> in the seven DirectVulkan TUs becomes MGB_CTX->
  (DirectVulkan.cpp 12, BackendObject_DirectVulkan.cpp 2, UniformManager.cpp 14,
  VkClearManager.cpp 1, VkRenderPassManager.cpp 3, VkTextureManager.cpp 2,
  VulkanRenderer.cpp 130 occurrences on 127 lines); each TU includes
  <MG_Pipe/PipeInputsSwitch.h> right after its MG_State/GLState/Core.h include
  (VkRenderPassManager.cpp after its include block, it never included Core.h).
- The 43 MOBILEGL_ASSERT(pGLContext) / (pGLContext != nullptr) lines become
  MOBILEGL_ASSERT(MGB_CTX_LIVE, ...): identical in every INFO build (the macro is
  empty there) and still a null-context assert in a DEBUG build.
- The six code-bearing guards are like-for-like pointer tests in the pull arm:
  if (MGB_CTX_LIVE) at the two InvalidateCompileEnv sites, MGB_CTX_LIVE in the
  XFB query counter condition and the ternary that snapshots the paused-primitive
  counter, !MGB_CTX_LIVE in BeginXfbCaptureForDraw, MGB_CTX_LIVE && in the
  provoking-vertex resolve. No semantic rewrite: under push MGB_CTX_LIVE is simply
  "a context exists", the real meaning of these guards is P2's business.
- VertexInputStateFactory.h's comment stops naming pGLContext so purity gate C
  (grep -rc pGLContext MobileGL/MG_Backend/DirectVulkan) reads 0 in every file.
- The 12 SyncPersistentMappedRange and 3 SyncGpuWrites sites of the D10 table are
  untouched; PipeStats AddCalls literals unchanged.
- Proof on the pull build (Release/INFO, LTO off, vs feat/disaggregated@087685d1):
  symbol_report --threshold 0 -> 27799 symbols, 0 added / 0 removed / 0 resized /
  0 renamed, .text 10792579 -> 10792579 (+0); nm --defined-only name set identical;
  ctest -N name set identical (2334); unit 1466/1466; DirectVulkan integration lane
  427/427 on lavapipe (two ArmedWhenTheEnvironmentPinsItOn entries flake under -j 4
  exactly as on the baseline and pass serially). The push build compiles and links.
2026-09-06 04:41:28 -04:00
swung0x48 d4504e30f8 [Refactor] (Espryt): route every frontend read through MGB_CTX - 113 arrow sites sed'd, 9 non-arrow lines converted (the fb-slot cache keys on MGB_CTX_IDENTITY); pull build byte-identical
- P1 package B (BRIEF-P1 C.2): the four DirectGLES TUs include <MG_Pipe/PipeInputsSwitch.h> right after
  their MG_State/GLState/Core.h include (Managers.cpp after its Managers.h include) and spell every
  frontend read MGB_CTX->Accessor(...). In the pull build MGB_CTX is MG_State::pGLContext, so the
  code is the tree before this commit token for token; in the push build it is &gPipeInputs, the block
  the frontend fills at every verb boundary.
- 113 arrow occurrences on 113 lines went through the mechanical sed (DirectGLES.cpp 91, Managers.cpp 15,
  MultiDraw.cpp 5, Utils.cpp 2). The 9 non-arrow lines follow D9: Managers.cpp's five bare/compound
  guards and three ternary conditions become MGB_CTX_LIVE (UniquePtr::operator bool spelled out, so the
  pull build does not move); DirectGLES.cpp's GetFramebufferBindingSlotFast keys its static cache on
  MGB_CTX_IDENTITY (a const void* compare) instead of pGLContext.get().
- The cache refill loop dereferences MGB_CTX once into a local reference and reads the slots through it,
  instead of &MGB_CTX->GetFramebufferBindingSlot(i) per iteration as D9 spells it: a store into
  g_fbSlotCache (a pointer) may alias the unique_ptr's own pointer under clang's TBAA, so the per-iteration
  spelling re-reads pGLContext inside the loop and grows the three functions the loop is inlined into
  (SyncCurrentProgram +16, ForceBindCurrentFBO +9, BlitNamedFramebuffer +1; .text +32). Hoisting the
  dereference restores the single load and a zero .text delta.
- grep -rc pGLContext MobileGL/MG_Backend/DirectGLES reports 0 in every file; symbol_report --threshold 0
  against the 087685d1 baseline: 0 added / 0 removed / 0 resized / 0 renamed, .text +0, nm --defined-only
  set identical. The only bytes that move are __LINE__ immediates in RecordError sites after the inserted
  include line. The 8 SyncPersistentMappedRange and 3 SyncGpuWrites sites and the PipeStats AddCalls
  literals are untouched.
2026-09-06 04:41:28 -04:00
swung0x48 9087f13308 [Fix] (Pipe): let a readback fill the transform-feedback state its emulation reads
- the depth/stencil read emulation opens a ScopedEmulationDrawState that pauses an active
  capture around its own draw, so ReadPixels/GetTexImage read IsTransformFeedbackActive and
  IsTransformFeedbackPaused; without the two kReadback rows every emulated readback aborts
  with Fatal{UnmigratedPipeInput, "IsTransformFeedbackActive@ReadPixels"} once the Espryt
  sites are converted
2026-09-06 04:41:15 -04:00
swung0x48 44ffafb2dc [Test] (Pipe): pin the pre-fill window - every stamp of 0 is stale at serial 0, a read before any fill aborts naming "<none>", and the FATAL=0 child leaves through std::exit so the teardown summary is observed
- PipeCatalogue.PipeInputFieldsStartUnfilled asserted "unfilled" only after setting the serial to 1 by hand, stepping around the serial-0 window; it now asserts every field stale on a value-initialised state first (sticky fields included).
- PipeInputsTest.ReadingBeforeAnyFillAbortsNamingNoVerb: a live context, no fill, gPipeInputs.GetLineWidth() in a forked child; the parent expects SIGABRT, exactly Fatal{UnmigratedPipeInput, "GetLineWidth@<none>"}, no PipeVerifyDiffer and no arming line. In a verify build the child sets Features.PipeVerify first, the lane's shape.
- VerifyFatalOffLogsTheDivergenceAndContinues: the child _exit(0)ed, so VerifyState's destructor never ran and the "verify summary" line was covered only by a lane run; std::exit(0) runs it, and the parent now asserts "2 divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0".
2026-09-06 02:55:17 -04:00
swung0x48 12b57055b7 [Docs] (Pipe): record why the forwarders carry no poison check, the InHook re-entry guard, and the Index slot no FillPoints.def row can fill
- The seven F-class forwarders are the declared exception to D4's "every accessor body": a forward is a live call, not a stored value, and InvalidateCompileEnv is reached from backend initialisation before any verb has filled, where a check would be Fatal{...@<none>} on every start. Their sticky stamp is consulted by no accessor; the tests pin it through MGPipeInputFieldIsFresh directly. Written down so P2's tracker does not "fix" the missing check.
- MGPipeVerifyReadHook: InHook is what keeps a GetProgramForDraw-triggered backend re-entry from recursing into a second hook, and the whole-field re-read is a per-read cost to keep in mind when reading the verify lane's wall time.
- GetBufferBindingSlot(Index) is polymorphic on GLContext (the bound VAO's element-buffer slot) and null in the block by design: a push Fatal there is not a missing row. No backend reads it today.
2026-09-06 02:55:17 -04:00
swung0x48 d0ff647581 [Fix] (Pipe): re-arm the verify comparator when any of its three knobs changes, not only when PipeVerify does
- ArmVerify latched PipeVerifyFatal and PipeVerifyCorrupt at the moment PipeVerify changed; a later change to either without a PipeVerify toggle was not seen, so 878db2c4's "re-parse when their value changes" held for one knob of three.
- The latch now keys on all three Features values. A lane loads Features before its first fill, so it still arms once per process; cost is two Bool compares and one String compare per fill, verify builds only.
2026-09-06 02:55:17 -04:00
swung0x48 30d72c5b4e [Fix] (Pipe): refuse a stamp of 0 as fresh on both branches - a read before the first fill is Fatal{UnmigratedPipeInput, "<Field>@<none>"}, not default storage
- MGPipeInputFieldIsFresh (gen_pipe.py gen_filled, emitted into PipeFilled.inc) compared FilledGen == CurrentVerbSerial for a non-sticky field; before the first MGPipeFillForVerb both are 0, so 55 of the 63 fields read as fresh and served their default-constructed storage silently, with no log line and no abort. Only the four raw-pointer O-class accessors tripped, through their null-base checks.
- D6 says the serial starts at 1 so that FilledGen == 0 means never filled, and names the window "<Field>@<none>"; the predicate now refuses a stamp of 0 before consulting the sticky branch or the serial, so the window is the poison's case as documented. This is the window E's risk table expects the verify lane to find (an init-time read, the first link, anything reached from eglMakeCurrent).
- Reproduced with the reviewer's pre-fill program (a live context with line width 7, no fill, gPipeInputs.GetLineWidth()): stored=0, rc=0 before; rc=134 with exactly Fatal{UnmigratedPipeInput, "GetLineWidth@<none>"} after, with and without Features.PipeVerify set first.
- The compare-at-read hook arms at the first fill and cannot see this window either; a static_assert next to it pins that a verify build always carries the poison, which is what covers the reads before arming.
2026-09-06 02:55:17 -04:00
swung0x48 d9f4698d98 [Test] (Pipe): give every PipeInputsTest process its own log file, and cover the compare-at-read arm, the three knob parsers and VERIFY_FATAL=0 with forked children
- gtest_discover_tests runs each case as its own process; under ctest -j the five shared one fixed log path, each main() unlinked it and each fork parent re-read it by path, so a sibling's Fatal line or unlink landed in another case's assertion (red 40/40 at -j 8, for a reason unrelated to the poison). The name now carries the pid and the file is removed on the way out; a forked child inherits the path on purpose.
- MutatedFieldIsNamedAtRead: the first falsifier of MGPipeVerifyReadHook - the child arms verify, fills DrawArrays, reads GetLineWidth (completes), mutates the live context's line width and reads again, and the parent expects SIGABRT with Fatal{PipeVerifyDiffer, "GetLineWidth@DrawArrays", verb=<serial>, where=read} and no where=entry.
- PoisonOmitKnobArmsTheOmission / BadPoisonOmitKnobIsFatalNamingTheKnob / VerifyCorruptKnobNamesTheFieldAtEntry / BadVerifyCorruptKnobIsFatalNamingTheKnob / VerifyFatalOffLogsTheDivergenceAndContinues: the knob parsers through MG_Config::Features, their arming lines, the exact Fatal{PipeVerifyBadKnob} text, and a FATAL=0 run that logs two divergences at consecutive serials and exits 0.
- OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale now loops every field: fresh iff in kTextureOp's mask and not the omitted one, so the name is true.
- The fork/waitpid/log-delta shape is one RunInChild helper; every case is still a visible skip where its switch is off.
2026-09-06 02:31:56 -04:00