Compare commits

..
131 Commits
Author SHA1 Message Date
swung0x48 c20e2f2b67 [Fix] (scripts): attribute G7 trip over the failing assertion BLOCKS - gtest prints the field name among the compared expressions, so a per-line match calls a real trip inconclusive 2026-09-08 07:21:19 -04:00
swung0x48 680ea63360 [Test] (MG_IntegrationTest): baseline the CSO slot leak case after two warm-up rounds so it measures growth with the churn, not the first draws one-off slots 2026-09-08 07:09:25 -04:00
swung0x48 959ca30810 [Test] (MG_Test): declare the new respecify-repair case in ResourceEmitTest pull-skip list so the pull and push ctest name sets stay identical (G2) 2026-09-08 07:09:25 -04:00
swung0x48 0bff6875b3 [Fix] (MG_Impl, DirectGLES, scripts): take the cheap review minors - the stale validate-point comment, G7's whole-log trip grep and missing INT/TERM traps, G5's CRLF stdout, and the dangling-twin invariant at its member 2026-09-08 07:04:20 -04:00
swung0x48 707bced438 [Fix] (MG_Impl, MG_Pipe, MG_IntegrationTest): close the four cross-package rows nobody claimed - symmetric vertex-input pairing asserts, the counter G10 reads, and the buffer ABA's Magma-only evidence 2026-09-08 07:04:19 -04:00
swung0x48 83c8101a9b [Fix] (DirectGLES): record the fp64 narrowing's dropped SyncGpuWrites as a ruled P3a deviation at the site, with its blast radius 2026-09-08 07:00:28 -04:00
swung0x48 2a5e0195b8 [Fix] (DirectGLES): stop on an armless P3a subsystem verdict now that every LEGACY_MEMOS=0 lane pins an explicit 0x1ff arm - warning and continuing drew through an unconfigured driver VAO 2026-09-08 07:00:28 -04:00
swung0x48 afda613bd9 [Fix] (DirectGLES): publish GLESBufferResource::hostBytes under pendingMutex - it was the one member the off-thread SubData/FlushRange arm wrote with neither a lock nor an atomic 2026-09-08 07:00:28 -04:00
swung0x48 433f51a065 [Fix] (MG_Impl, MG_Test): publish the create a respecify's handle never got - a buffer born while the resource op table was unregistered had every later respecify refused and drew through id 0 with no diagnostic 2026-09-08 07:00:06 -04:00
swung0x48 9ba5d7ba1e [CI] (scripts, test.yml, DirectGLES): hash FlushPendingRangesFrom as G5's eleventh row - a push build compiles no FlushPendingRangesNow, so the tenth row protected text the shipping build never sees 2026-09-08 06:54:25 -04:00
swung0x48 f0beefa85c [Test] (MG_Test, MG_IntegrationTest): assert a destroyed vertex array returns its CSO slot, on the client allocator and on the DirectVulkan lane where it leaked 2026-09-08 06:49:59 -04:00
swung0x48 78ff014547 [Fix] (MG_Impl, MG_State, DirectGLES): give the vertex-elements CSO a backend-neutral death path - the client minted every VAO's slot and only Espryt ever freed one, so under Magma each VAO leaked a slot and a 1.3 KB applier record for the life of the process 2026-09-08 06:49:59 -04:00
swung0x48 3e298c9ad1 [CI] (Pipe): add FlushPendingRangesNow's three-tier drain to G5's byte-identical set 2026-09-08 06:02:36 -04:00
swung0x48 fd6b5bdbf2 [Fix] (Pipe): restore and rebuild on every exit path of the vertex-input negative control, and keep its logs out of the repository 2026-09-08 06:02:36 -04:00
swung0x48 8d3cd67b94 [CI] (Pipe): select the P3a subsystem and map-persistent lanes in the only job that unpacks a push build 2026-09-08 06:02:36 -04:00
swung0x48 f6c7dcd1b6 [Fix] (Pipe): raise the four CSO stats lanes to the P3a subsystem mask so they stop asserting on a configuration nothing ships 2026-09-08 06:02:36 -04:00
swung0x48 4ad88214cd [Test] (Pipe): count the map-persistent round trips over a re-specified adopted arena in both arms, now that the VAO rebind fix is in the branch 2026-09-08 06:02:36 -04:00
swung0x48 4412cef00c [CI] (Pipe): gate that the buffer pool, the deferred-release drain and the three rings did not move 2026-09-08 06:02:36 -04:00
swung0x48 6e9943b36e [Test] (Pipe): assert a regrown adopted store costs one map-persistent round trip per storage definition, not one per draw 2026-09-08 06:02:36 -04:00
swung0x48 0cb46fe2bd [Test] (Pipe): reproduce the buffer handle ABA through public GL and prove the pre-rekey guards are what stops it 2026-09-08 06:02:36 -04:00
swung0x48 83302ca247 [Test] (MG_Test): scope the pipe resource op table in BufferTest's fixture the way it scopes BufferBackendOps - a push build installs both at bring-up, and these 86 cases are the BufferBackendOps arm's 2026-09-08 06:00:46 -04:00
swung0x48 3c55e02797 [Fix] (Espryt): republish the live shadow base on the ensure path so a lazily twinned store is not left with none for the readers that hold no frontend object 2026-09-08 05:27:19 -04:00
swung0x48 75ea7ee2df [Fix] (Espryt): ask the frontend object whether the shadow the ensure path is about to upload holds bytes, instead of a descriptor no content call refreshes 2026-09-08 05:27:03 -04:00
swung0x48 13b380feda [Fix] (Espryt): resolve an attribute's pushed vertex buffer by its attribute index, not by the GL binding point the wire attribute carries 2026-09-08 05:26:48 -04:00
swung0x48 ed83424c75 [Fix, Test] (Espryt): pin that a live host map keeps the handle arm's draw probe dirty and that a backwards generation is refused 2026-09-08 04:52:15 -04:00
swung0x48 df055eab13 [Fix] (Espryt): treat an unbound vertex-elements record as a memo miss and record the applier-reset serial rule the twin's gate depends on 2026-09-08 04:52:15 -04:00
swung0x48 511b3752c0 [Fix] (Espryt): make the vertex-input bit require the resource bit and give both new subsystems a named verdict when the knobs leave no arm 2026-09-08 04:52:15 -04:00
swung0x48 b28058f37c [Fix] (Espryt): walk all 32 attribute slots so a vertex-elements record that shrinks still disables the arrays it dropped 2026-09-08 04:52:15 -04:00
swung0x48 14137bc9a6 [Fix] (Espryt): refuse a backwards generation and an out-of-range slot in the handle-keyed GetOrCreate instead of adopting them 2026-09-08 04:52:15 -04:00
swung0x48 39933613ae [Fix] (Espryt): re-read the client's shadow base at every use and forget it at the two events that free the allocation it names 2026-09-08 04:52:15 -04:00
swung0x48 816373ffd9 [Fix] (Espryt): keep the live-map question in the handle arm's draw-clean probe instead of a record field P3a pins false 2026-09-08 04:52:15 -04:00
swung0x48 7047331a41 [Fix] (Espryt): clamp the handle readback to the backend store past its offset, memoise the buffer handle lookup, and write down the two contracts the ops owed their callers 2026-09-08 04:52:15 -04:00
swung0x48 7ef8cb93fc [Fix] (Espryt): leave FlushPendingRangesNow as this file's only definition and call the one shared range ladder from both arms 2026-09-08 04:52:15 -04:00
swung0x48 b97a228cea [Fix] (Espryt): carry the adopted-store respecify's buffer-id generation bump into the handle arm as well 2026-09-08 04:52:15 -04:00
swung0x48 3840cf734b [Test] (Espryt): extend the slot-table suite to the buffer kind and pin that its death crosses as resource_destroy 2026-09-08 04:52:15 -04:00
swung0x48 68db6db7f8 [Refactor] (Espryt): key the narrowed fp64 vertex stream on the buffer's handle instead of its lifetime id 2026-09-08 04:52:15 -04:00
swung0x48 cd05de504e [Refactor] (Espryt): drive the driver VAO from the pushed vertex-elements record and retire the wrapping index-slot version and its identity patch 2026-09-08 04:52:15 -04:00
swung0x48 ce24e2a734 [Refactor] (Espryt): answer a readback through the reverse channel and bump the mutation epoch after it, never before 2026-09-08 04:52:15 -04:00
swung0x48 4e44650199 [Refactor] (Espryt): take the buffer ops by handle and payload instead of by frontend object reference 2026-09-08 04:52:15 -04:00
swung0x48 9951961d9c [Refactor] (Espryt): give the buffer resource its own {slot, gen} table instead of hanging it off the frontend object 2026-09-08 04:52:15 -04:00
swung0x48 31e370bed5 [Test] (Pipe): pin the base instance across a make-current and both of the validate point's exits, the record surviving one, the draw-time bind mask, the create-destroy pairing latch, and the bind-mask table against literals 2026-09-08 04:47:51 -04:00
swung0x48 0c55560510 [Fix] (Pipe, State): look the resource handle up on the content paths instead of minting it, publish the bind mask from the two draw-time emitters, pair the destroy with the create's own latch, and state the record-lifetime rule the applier now holds 2026-09-08 04:47:51 -04:00
swung0x48 f11e78b0a4 [Feat] (Pipe, GLImpl): set the pending base instance from the three glDraw*BaseInstance entry points, through a pull-safe macro beside MGP_FILL 2026-09-08 04:47:51 -04:00
swung0x48 d9bde13127 [Fix] (Pipe): stop the tracker's context Reset eating the pending base instance the same call is about to read, and clear it on the validate point's no-context exit too 2026-09-08 04:47:51 -04:00
swung0x48 cc427ec4de [Test] (Pipe): pin the buffer and vertex-input emitters - every attribute field survives the wire, a bare baseInstance change still emits, and the index-buffer bit ignores unrelated writes 2026-09-08 04:47:51 -04:00
swung0x48 b11bb9650a [Feat] (Pipe): widen the dirty-surface scan to MG_State so the MGP_NOTE_MUTATION sites stop being outside the gate 2026-09-08 04:47:51 -04:00
swung0x48 14efd6eb24 [Feat] (Pipe): push the bound VAO's format, its vertex buffers with an explicit baseInstance, and its index binding as their own calls 2026-09-08 04:47:51 -04:00
swung0x48 874d1ee77d [Feat] (Pipe, State): mint a {slot, gen} handle for every buffer object and publish its create, respecify, sub-data, flush, readback and destroy as pipe calls 2026-09-08 04:47:51 -04:00
swung0x48 b9eaa47480 [Build] (Config): bump CoreVersion to 26.9 so GL_VERSION, the POST stamp and the Magma pipeline-cache key follow the 26.09 plugin version 2026-09-08 04:42:02 -04:00
swung0x48 42d43af25b [Build] (Android): bump the plugin version to 26.09 - versionCode 26090000 + this month's revision count, versionName 26.09.<hash> 2026-09-08 04:41:03 -04:00
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 149e26a79a [Fix] (Espryt): do not return a reference through a null slot pointer, and stop a comment claiming a memo that is no longer there
- SyncTextureObjectToBackend re-resolves its slot after the nested glTextureView sync. The
  assert that it is still there is right - the caller holds the frontend object, so nothing
  can reclaim its slot - but the function returns a REFERENCE, and in a release build the
  assert is gone and the deref is not. It now falls back to GetOrCreate and puts the twin
  the caller is about to use back.
- ResolveVaoTwin documents why its raw twin pointer survives the whole draw on both arms
  instead of pointing at TwinLookupMemo, which the handle arm does not compile.
2026-09-07 23:18:09 -04:00
Swung0x48 3160c4b85b [Test] (Espryt): pin the twin table identity contract - a reclaimed slot is a new handle and the stale one resolves to nothing
- Five cases against BackendSlotTable directly, through a stand-in state object that carries
  only GetLifetimeId(), so none of them needs a GLContext, a driver or ES entry points.
- The load-bearing one is the ABA: an object dies, the sweep returns its slot, the next
  object takes the same slot with a moved generation, and the predecessor handle answers
  null instead of the successor twin. That is the property the address key could only paper
  over with a weak_ptr.
- Gen moves on reuse and only on reuse; Find never mutates the table (which is what lets
  SyncTextureObjectToBackend drop its second Find); a whole table saves, resets with = {} and
  restores, which is the shape ScopedDirectGLESTextureBindings needs; and two tables of one
  kind agree on a single object handle, because the identity comes from the client allocator
  rather than from either table.
2026-09-07 23:18:09 -04:00
Swung0x48 bd2092f4eb [Refactor] (Espryt): key every backend twin on {slot, gen} instead of the frontend object heap address
- ResolveVaoTwin, SyncCurrentProgram and BindCurrentFBO stop consulting the three
  TwinLookupMemos on the handle arm. The memo existed to replace the registry hash probe
  with an array index, and the slot table Find already IS that array index; its safety
  argument - owner-equality of a weak snapshot against a recycled heap address - is
  answered by the generation instead of re-derived per lookup. OwnerEquals, the memo
  template and its three instances are now compiled only under MOBILEGL_PIPE_LEGACY_MEMOS.
- UnitSamplerLookupMemo compares {slot, gen} instead of owner-equality, and keeps its "a
  miss is never cached" contract verbatim: the sampler twin is created later in the same
  draw by the program pass.
- UnitBindingsSnapshot holds lifetime ids rather than weak_ptrs under push. It cannot hold
  handles: a bound-but-never-synced texture has no twin and so no handle, and two of those
  would read as equal. A lifetime id exists before the twin does and is never handed out
  twice, which is the property the weak_ptr was there for.
- SyncTextureObjectToBackend keeps its by-value copy only to hold the twin alive across the
  nested glTextureView sync; the second Find-or-create and the put-the-twin-back repair are
  gone on the handle arm, because nothing there erases a live entry.
- The one direct-iteration site walks ForEachLive, which hands over a strong reference to
  the framebuffer instead of the map key - the raw frontend address it had to null- and
  expiry-check by hand before dereferencing.
- GetFramebufferBindingSlotFast becomes GetFramebufferBindingSlotChecked and, under push,
  reads MGB_CTX->GetFramebufferBindingSlot(target) every time. This closes the P1 accessor
  bypass: the cached raw pointer ran the checked accessor once per context change and then
  handed out the pointee forever, so the per-verb poison stamp and the verify read-hook
  were skipped at all five call sites.
- Every one of these is a push-build arm; the pull build compiles the pre-P2 text and its
  symbol report stays 0 added / 0 removed / 0 renamed with no new resize.
2026-09-07 23:18:09 -04:00
Swung0x48 f4dbea2300 [Feat] (Espryt): give the backend a dense {slot, gen} twin table beside the address-keyed registry
- SlotTables.h: BackendSlotTable<StateObject, BackendObject, kKind>, indexed by
  MGPipeHandle::Slot and validated by Gen, with the handle minted by the client
  MGPipeSlotAllocator off the frontend object GetLifetimeId(). A lookup is one bounds
  check plus one array index, and unlike the registry Find it never mutates the table,
  so a returned BackendPtr* is not invalidated by the next call on it.
- StateBackendObjectRegistry keeps its name, its signature and all ~40 call sites, and
  becomes the two-arm facade ARCHITECTURE.md 9.6 asks for: the pre-handle map under
  MOBILEGL_PIPE_LEGACY_MEMOS, the slot table under MOBILEGL_PIPE_PUSH, chosen once per
  process by EsprytSlotTablesEnabled() off kMGPipeSubsystemEsprytSlots. A clear bit with
  MOBILEGL_PIPE_LEGACY_MEMOS=0 leaves no arm at all and is Fatal{PipeLegacyMemosDisabled}.
- The kind is a template parameter only in the push build (MGB_TWIN_KIND_ARG): a third
  template argument would rename every instantiation and G1 wants the pull build byte
  identical. Pull-build symbol report is 0 added / 0 removed / 0 renamed and adds no
  resize beyond the three RenderState symbols the contract commit already moved.
- ForEachLive replaces begin()/end() under push and hands the callee a strong reference
  to the state object instead of the map key, which was the raw frontend address.
- Nothing switches over yet: the tables are built and reachable, and the twins still go
  through whichever arm the bit selects.
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
119 changed files with 27555 additions and 345 deletions
+145 -12
View File
@@ -420,13 +420,20 @@ jobs:
echo "::error::nm --defined-only sees only ${defined} symbols in ${BUILD_DIR}/libMobileGL.so - it looks stripped, so the two checks below could not have failed honestly"
exit 1
fi
for entry in MGPipeVerifyInputs MGPipeFillForVerb; do
if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "${entry}"; then
echo "::error::libMobileGL.so defines no ${entry}: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing"
exit 1
fi
done
echo "libMobileGL.so defines MGPipeVerifyInputs and MGPipeFillForVerb (${defined} defined symbols)"
if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "MGPipeVerifyInputs"; then
echo "::error::libMobileGL.so defines no MGPipeVerifyInputs: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing"
exit 1
fi
# The per-verb entry point, under EITHER of its two names. P2 renames
# MGPipeFillForVerb to MGPipeValidateForVerb (the body becomes the tracker's walk and
# the fill is one of its five steps), so this check has to accept both or it goes red on
# the rename for a reason that has nothing to do with what it tests. What it tests is
# unchanged: that the library HAS a per-verb entry point compiled in.
if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -qE "MGPipeValidateForVerb|MGPipeFillForVerb"; then
echo "::error::libMobileGL.so defines neither MGPipeValidateForVerb nor MGPipeFillForVerb: there is no per-verb entry point in this artifact, so nothing fills the block the comparator compares"
exit 1
fi
echo "libMobileGL.so defines MGPipeVerifyInputs and a per-verb entry point (${defined} defined symbols)"
- name: Show ccache stats
if: always()
@@ -542,6 +549,72 @@ jobs:
ctest --output-on-failure -L integration-verify --no-tests=error
fi
# The push-only unit tests, on the verify runtime.
#
# WHY HERE AND NOT IN `test`. The entries themselves are registered in EVERY build - they
# have to be, or `ctest -N` would stop matching name-for-name between the pull and the push
# build (gate G2). What is push-only is what they assert about: MGPipeRenderStateSpans.cpp
# and PipeApply.cpp are appended to SOURCE_FILES inside the `if (MOBILEGL_PIPE_PUSH)` block,
# which is exactly how the pull build stays symbol-identical, so in a pull build each case
# opens with `#if !MOBILEGL_PIPE_PUSH GTEST_SKIP() << "push not compiled in"`. The `test`
# job therefore runs G6's chunk-table walk and G10's residual assertions as a column of
# skips: CI executes the NAMES and never one of the assertions. This job unpacks a build
# that compiled them, so it is the first place in CI where they actually run.
#
# This artifact already carries them - the packaging step above tars
# ${BUILD_DIR}/MobileGL/MG_Test whole - so the whole cost is the run, which is ~14 s for
# ~1490 entries. --no-tests=error, because a packaging change that stopped shipping the
# unit binaries would otherwise report a green run of nothing.
- name: Unit tests on the verify runtime (G6, G10)
working-directory: build-verify
run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)"
# The always-on P2 and P3a negative controls (G8, G10, G12), which are labelled
# integration-gpu and not integration-verify - they are about the handle key, the subsystem
# switch and the map-persistent counter, not about the comparator - so the lane above does
# not reach them. They are run HERE because this is the only CI job that unpacks a
# MOBILEGL_PIPE_PUSH build: every one of them reads a counter out of the library's summary
# line, and both the counters and their brackets are #if MOBILEGL_PIPE_PUSH, so in the pull
# `integration` job the entries exist (gate G2 requires the same names in both builds) but
# have nothing to assert.
#
# An arm whose subsystem has not landed on this tree SKIPS with the reason (never absent,
# never a green that asserted nothing), so this step is green through the P2 and P3a landing
# orders and starts asserting as each package arrives.
#
# The environment is the sibling step's, deliberately and in full: these entries run the
# same DirectVulkan binary through the same runner, so the three MOBILEGL_MAGMA_* fixes it
# needs apply here too, and a crash here has to leave a core for the same black-box flow.
# The step above is the only reason those lines exist in this job; a control that crashed
# without one would be the hardest failure in the job to diagnose.
#
# THE -R ALTERNATIVES ARE TEST-NAME PREFIXES, NOT LANE LABELS, and each one is deliberately
# the SHORTEST string that still selects only what it means to. `ResourceSubsystem` (not
# `ResourceSubsystemControl`) is what reaches the eight
# DirectGLES.ResourceSubsystemOn./Off.LargeArenaAdoptionScenario.* entries - the A/B lanes
# whose entire purpose is that the handle path and the legacy BufferBackendOps path must
# agree about an adopted store - as well as the two ResourceSubsystemControl. entries.
# `MapPersistentRoundtrip` is singular because LargeArenaAdoptionScenario's case is
# `AnAdoptionCostsExactlyOneMapPersistentRoundtrip`; the plural matched only the lane PREFIX
# of the other one. Both mistakes were silent: this is the only CI job that unpacks a push
# build, so an entry the filter misses is either never run under the P3a bits at all or runs
# only in the pull `integration` job, where a MOBILEGL_PIPE_PUSH value steers nothing
# (Config.h declares the field inside the push guard) and both arms are the same legacy path.
# A lane that cannot go red where it is installed is not a gate (ROADMAP.md:7).
- name: The handle-ABA, CSO and P3a subsystem controls (G8, G10, G12)
working-directory: build-verify
env:
MOBILEGL_ITEST_REQUIRE_GPU: "1"
MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1"
MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1"
MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1"
run: |
ulimit -c unlimited
sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p'
ctest --output-on-failure -L integration-gpu \
-R 'HandleRecycle|CsoContentAddressing|ResourceSubsystem|MapPersistentRoundtrip' \
--no-tests=error -j 4
# The arming lanes' logs, and ONLY those. Each lane shares one MOBILEGL_LOG_FILE_PATH and the
# library opens it fopen(path, "w"), so after an ambient lane of 400-odd processes the file
# holds the LAST one - grepping it would say nothing about the other 405 and would red a
@@ -1483,10 +1556,21 @@ jobs:
runs-on: ubuntu-latest
# Deliberately independent of build-linux: these are source-level gates, they take
# seconds, and a broken build must not hide a drifted interface.
env:
# P3a's base ref, for the G5 region gate below. It is 44c2b5cf and NOT the workflow's
# baseline_sha input: that input is the SYMBOL baseline (087685d1, P1's G1 reading) and it
# is empty on a push, whereas this gate asks "did the buffer pool, the deferred-release
# drain and the three rings move since the phase started". INTEGRATOR-DECISIONS ID-1 is
# where the ref comes from.
BASELINE: "44c2b5cf"
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
# The G5 gate reads Managers.cpp at BASELINE with `git show`, which a depth-1 checkout
# does not have. Nothing else in this job needs history.
fetch-depth: 0
# The seven generators all read MG_Pipe/*.def, so regenerating and diffing is what
# keeps the two interface tables, the wire records, the verify comparators, the
@@ -1525,11 +1609,60 @@ jobs:
fi
echo "no fprintf(stderr/stdout / printf( / puts( / std::cout|cerr under MobileGL/MG_Backend or MobileGL/MG_State"
# Informational: the frontend mutation surface an MGPipe aggregate generation has to
# cover. It becomes a gate in P2, when the mapping file exists to diff against
# (ROADMAP.md:18 puts the first mapping round in P2, not P1).
- name: MGPipe dirty-surface report
run: python3 scripts/gen_pipe_dirty_surface.py --summary
# A GATE as of P2, which is when MG_Pipe/DirtySurface.def exists to diff the scan against
# (ROADMAP.md:18 puts the first mapping round in P2). --check fails BOTH directions: a
# mutator the scanner finds with no row in the 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 the half that keeps --check honest, and it is not optional. A completeness
# check that silently stopped checking produces exactly the same green as a complete
# mapping; the self-test feeds it two canned negative controls (a mutator withheld from the
# def, a row naming a function that does not exist) and fails if either fails to trip. Same
# shape as gen_pipe.py --self-test and check_include_closure.py above.
#
# What this gate does NOT cover is written into DirtySurface.def's header rather than left
# implicit: the scanner attributes a mutation inside a lambda to the enclosing function,
# reads a mutation published through a helper as deferred, and scans only MG_Impl/GLImpl -
# so the four MGP_NOTE_MUTATION sites in MG_State are outside it entirely. This is a
# completeness gate over what the scanner can see; the semantic proof is the verify lane.
- name: MGPipe dirty-surface mapping is complete (G9)
run: |
python3 scripts/gen_pipe_dirty_surface.py --check
python3 scripts/gen_pipe_dirty_surface.py --self-test
# A GATE as of P3a (G5). "pool 与延迟释放原样搬" (ROADMAP.md:19) is meant literally: the
# buffer pool, the deferred-release drain and the three persistently mapped rings move
# VERBATIM, and ARCHITECTURE.md:515 says why - their retire happens only inside Present, so
# a batching or ordering change there starves them, and nothing else in this workflow can
# see it. P3a rewrites the rest of Managers.cpp by design, so a file diff says nothing; the
# script extracts the ELEVEN named bodies and compares their hashes on their own.
#
# Eleven and not ten (ID-15): Managers.cpp carries the three-tier flush drain TWICE, once
# per preprocessor arm, and a push build compiles only FlushPendingRangesFrom while the
# untouched FlushPendingRangesNow lives in the `#else`. Hashing the pull name alone would
# protect text no shipping build compiles, so both are hashed - the pull ladder against
# BASELINE, the push ladder against a sha pinned in the script at 3e298c9a, because that
# one was born in P3a and has no body at the base ref to compare with.
#
# Scoped to the disaggregation branch and to a manual dispatch, deliberately: the question
# is "did these eleven move since P3a started", and BASELINE is P3a's base ref. On dev,
# where unrelated buffer fixes land on their own schedule, the same comparison would be
# asking a question nobody posed - it belongs with the TEMPORARY trigger lines at the top
# of this file and retires with them.
#
# --self-test is the half that keeps it honest, and it is not optional: a comparison that
# silently stopped comparing produces exactly the same green as eleven untouched bodies. It
# runs six canned controls - eleven bodies extracted, an untouched copy compared equal, an
# edit OUTSIDE them ignored, and each of the three perturbation targets (ClearBufferPool
# and BOTH flush ladders) reported BY NAME - and fails if any of them does not answer.
# Same shape as gen_pipe.py --self-test above.
- name: The buffer pool, the deferred-release drain and the rings did not move (G5)
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
run: bash scripts/p3a_untouched_regions.sh "${BASELINE}" HEAD
- name: The untouched-region gate can still fail (G5)
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
run: bash scripts/p3a_untouched_regions.sh --self-test
# Warning only for now: the disaggregation documents are still being written, and a
# lint that fails a rewrite in progress teaches people to ignore it. It becomes
+25
View File
@@ -27,6 +27,13 @@ option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery
# MGPipe/PipeInputs source is compiled, every MGP_FILL is ((void)0).
option(MOBILEGL_PIPE_PUSH "Backends read frontend state through the MGPipe PipeInputs block instead of MG_State::pGLContext (ARCHITECTURE.md 9.2 phase A)" OFF)
option(MOBILEGL_PIPE_VERIFY "Compile SnapshotFromGLContext() and the G4 per-verb shadow comparator; implies MOBILEGL_PIPE_PUSH; never shipped" OFF)
# Track H's old-versus-new arm (ARCHITECTURE.md 9.6). With a MOBILEGL_PIPE_PUSH bit clear
# the backend would still run the RE-KEYED memo code, so the bitmask alone stops being a
# valid A/B the moment a handle wave lands: this option compiles the pre-handle arm - the
# registries, OwnerEquals, the TwinLookupMemos, g_fbSlotCache, ComputePipelineStateHash,
# the address-keyed VaoDrawMemo - beside it, behind the same PipeInputs interface. ON for
# the whole migration window; it retires with the pull path itself at P13.
option(MOBILEGL_PIPE_LEGACY_MEMOS "Compile the pre-handle memo arm beside the {slot, gen} arm so Track H has a real A/B (ARCHITECTURE.md 9.6)" ON)
set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro")
set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds")
@@ -328,6 +335,7 @@ set(SOURCE_FILES
MobileGL/MG_Util/BackendLoaders/Vulkan/Loader.cpp
MobileGL/MG_Util/SelfTest/DriverBugProbes.cpp
MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp
MobileGL/MG_Util/SelfTest/DriverPost.cpp
MobileGL/MG_Util/SelfTest/DriverPostIterationRPWitness.cpp
MobileGL/MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.cpp
@@ -464,11 +472,25 @@ if (MOBILEGL_PIPE_VERIFY AND NOT MOBILEGL_PIPE_PUSH)
set(MOBILEGL_PIPE_PUSH ON)
endif()
# In a pull build the legacy arm is the ONLY arm, so the option cannot be off there.
# A normal variable, not a forced cache write, for the same reason as the two above.
if (NOT MOBILEGL_PIPE_PUSH AND NOT MOBILEGL_PIPE_LEGACY_MEMOS)
message(STATUS "MobileGL: MOBILEGL_PIPE_PUSH=OFF forces MOBILEGL_PIPE_LEGACY_MEMOS ON for this "
"configure: with nothing pushed it is the only arm there is")
set(MOBILEGL_PIPE_LEGACY_MEMOS ON)
endif()
if (MOBILEGL_PIPE_PUSH)
message(STATUS "MobileGL: PipeInputs push ON, appending the MGPipe fill sources")
list(APPEND SOURCE_FILES
MobileGL/MG_Backend/MGPipe/PipeInputs.cpp
MobileGL/MG_Impl/Pipe/PipeFill.cpp
# P2's contract: the chunk table and its subset hash, the in-process applier, and
# the client's {slot, gen} allocator. All three are push-only, which is how the
# pull build gains no symbol from P2 (G1) - a declaration emits nothing.
MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp
MobileGL/MG_Pipe/PipeApply.cpp
MobileGL/MG_Impl/Pipe/SlotAllocator.cpp
)
endif()
@@ -551,6 +573,9 @@ endif()
if (MOBILEGL_PIPE_VERIFY)
list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_VERIFY=1)
endif()
if (MOBILEGL_PIPE_LEGACY_MEMOS)
list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_LEGACY_MEMOS=1)
endif()
message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}")
+29 -5
View File
@@ -14,7 +14,7 @@ namespace MobileGL::MG_Config {
inline const String ProjectName = "MobileGL";
inline const String CoreName = "MobileGL Core";
inline const String CoreVendor = "MobileGL-Dev (BZLZHH, Swung0x48, Tungsten)";
inline const Version CoreVersion = {26, 8, 0, "-dev", VersionType::Development};
inline const Version CoreVersion = {26, 9, 0, "-dev", VersionType::Development};
inline const VersionStringFormatAttrib DefaultVersionStringFormatAttrib = {2, 2, 0, true, true};
inline const Uint64 CacheVersion = 0;
@@ -319,10 +319,22 @@ namespace MobileGL::MG_Config {
// --- MGPipe (the disaggregation plan's explicit frontend/backend boundary) ---
// MOBILEGL_PIPE_PUSH: per-subsystem bitmask selecting which state the frontend
// PUSHES over MGPipe instead of leaving the backend to pull it out of GLContext.
// 0 - the default and the only shipped value until the migration lands - is "pull
// everything", i.e. exactly today's behaviour. One bit of it also turns OFF
// client-side content addressing of CSOs, which is the negative control the CSO
// design is measured against. Accepts decimal or 0x-prefixed hex.
// 0 - the only shipped value until the migration lands - is "pull everything",
// i.e. exactly today's behaviour, and is the default of a PULL build, where the
// knob is meaningless anyway. A PUSH build defaults to every subsystem migrated so
// far (MG_Pipe::kMGPipeSubsystemsMigratedAtP3a), so MOBILEGL_PIPE_PUSH=0 in the
// environment is the all-pull control and 0x7f (kMGPipeSubsystemsMigratedAtP2) is
// the "P2 only" control P3a's A/B is run against. Accepts decimal or 0x-prefixed
// hex, and operators pass it as hex, so the bits are listed here (MG_Pipe/MGPipe.h
// owns them):
// 0x01 render state (create/bind_render_state + set_dynamic_state)
// 0x02 pixel pack 0x04 patch state 0x08 vertex attrib defaults
// 0x10 residual values 0x20 Espryt slots 0x40 Magma vertex input
// 0x80 resources (the resource_* family: the seven BufferBackendOps hooks)
// 0x100 vertex input (vertex elements / vertex buffers / index buffer)
// 1<<63 NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of
// CSOs, so every pipeline-version change mints a fresh CSO and the map is
// never probed. The negative control the CSO design is measured against.
Uint64 PipePush = 0;
// MOBILEGL_PIPE_VERIFY: per-draw, per-FIELD shadow comparison of the pushed state
// against a snapshot taken from GLContext the old way, printing the first field
@@ -349,6 +361,18 @@ namespace MobileGL::MG_Config {
// Fatal{UnmigratedPipeInput} (negative control B). Unknown name is
// Fatal{PipeVerifyBadKnob}.
String PipePoisonOmit;
// MOBILEGL_PIPE_HANDLE_ABA_CONTROL (negative control C, P2 brief D18): replace the
// OBJECT IDENTITY in every DirectVulkan vertex-input memo key with a constant, on
// whichever arm the run is on - the pre-handle (address, lifetime id) pair AND the
// handle arm's {slot, gen} generation - so a replacement object inherits its dead
// predecessor's resolved vertex bindings and HandleRecycleScenario.AbaControl asserts
// the WRONG pixels. That is what proves the reproducer still reproduces. D18 wrote
// this as "hash the raw BufferObject* instead of its lifetime id"; measured, the heap
// block is never handed back, so that spelling collided with nothing and the control
// went vacuous - see MagmaPipeArms.h's MagmaPipeAbaControlDefeatsIdentity for the
// measurement and for what the control still leaves standing. Under
// MOBILEGL_PIPE_PUSH only, so it cannot exist in a shipping pull build.
Bool PipeHandleAbaControl = false;
#endif
// MOBILEGL_PIPE_STATS: dump the boundary counters (bytes, calls, roundtrips,
// texture pulls, upload shapes, residual-block bytes, index mirror bytes).
+18
View File
@@ -7,6 +7,12 @@
// End of Source File Header
#include "Config.h"
#if MOBILEGL_PIPE_PUSH
// For kMGPipeSubsystemsMigratedAtP3a, the push build's PipePush default (the P2 constant
// beside it is the phase-by-phase control, not the default). Push-only, so the pull
// build's translation unit is unchanged.
#include <MG_Pipe/MGPipe.h>
#endif
#include <cerrno>
#include <cstdlib>
@@ -242,7 +248,18 @@ namespace MobileGL::MG_ConfigLoader {
// MGPipe. Nothing here needs adding to an allow-list: InitializeAcceptedEnvVariables
// accepts every MOBILEGL_ / LIBGL_ prefixed variable in the environment, so a name
// that starts with MOBILEGL_ is visible to these queries by construction.
#if MOBILEGL_PIPE_PUSH
// A push build with the knob unset runs every subsystem migrated so far, so the
// shipped path is the one the gates measure; MOBILEGL_PIPE_PUSH=0 in the
// environment is the all-subsystems-pull control that reproduces P1 exactly, and
// kMGPipeSubsystemsMigratedAtP2 (0x7f) is the phase-by-phase control - P3a's two
// subsystems off, everything P2 landed still on.
features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP3a);
#else
// Meaningless in a pull build: there is nothing to push. Config.h documents 0 as
// "pull everything" and that stays literally true.
features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0);
#endif
features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY");
#if MOBILEGL_PIPE_PUSH
// Defaults ON: read as a tri-state so only an explicitly falsy value turns it off.
@@ -250,6 +267,7 @@ namespace MobileGL::MG_ConfigLoader {
QueryEnvQuirkOverride("MOBILEGL_PIPE_VERIFY_FATAL") != MG_Config::QuirkOverride::ForceOff;
QueryEnvVariable("MOBILEGL_PIPE_VERIFY_CORRUPT", features.PipeVerifyCorrupt, "");
QueryEnvVariable("MOBILEGL_PIPE_POISON_OMIT", features.PipePoisonOmit, "");
features.PipeHandleAbaControl = QueryEnvFlag("MOBILEGL_PIPE_HANDLE_ABA_CONTROL");
#endif
features.PipeStats = QueryEnvFlag("MOBILEGL_PIPE_STATS");
// Defaults ON, so the flag has to be read as a tri-state rather than as a plain
+526 -23
View File
@@ -17,6 +17,10 @@
#include <MG_Util/Metrics/TextureMetrics.h>
#include <MG_State/GLState/Core.h>
#include <MG_Pipe/PipeInputsSwitch.h>
#if MOBILEGL_PIPE_PUSH
// P3a: the applier's vertex-input records the re-keyed draw-buffer memo is validated against.
#include <MG_Pipe/PipeApply.h>
#endif
#include <MG_State/GLState/ErrorState/Error.h>
#include <MG_State/GLState/TextureState/TextureObjectBuffer.h>
#include <MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h>
@@ -57,9 +61,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
static SharedPtr<MG_State::GLState::SamplerObject> g_rawDepthFetchSamplerState;
static SharedPtr<SamplerImpl::BackendSamplerObject> g_rawDepthFetchSamplerBackend;
#if MOBILEGL_PIPE_LEGACY_MEMOS
// Two objects are the same binding iff they share a control block. Raw addresses lie
// (a freed object's heap slot is reused), but a held weak_ptr pins the control block,
// so no later object can ever owner-equal a snapshot of its predecessor.
//
// This is the pre-handle identity mechanism, and it is compiled only for the legacy arm.
// On the {slot, gen} arm nothing needs it: a handle already cannot be reproduced by a
// recycled address, so there is no snapshot to owner-compare.
template <typename T>
static Bool OwnerEquals(const WeakPtr<T>& snapshot, const SharedPtr<T>& current) {
return !snapshot.owner_before(current) && !current.owner_before(snapshot);
@@ -130,6 +139,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// FBOs; 64 slots is plenty.
static TwinLookupMemo<MG_State::GLState::FramebufferObject, FramebufferImpl::BackendFramebufferObject, 6>
g_fboTwinLookupMemo;
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
// Cached addresses of the frontend's framebuffer binding slots. The frontend
// getter linear-scans its slot array per call and the draw path asks for these
@@ -142,9 +152,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
// exactly the pointer compare below.
using FbBindingSlot =
std::remove_reference_t<decltype(MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw))>;
#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS
static const void* g_fbSlotCacheContext = nullptr;
static Array<FbBindingSlot*, SizeT(FramebufferTarget::FramebufferTargetCount)> g_fbSlotCache = {};
static inline FbBindingSlot& GetFramebufferBindingSlotFast(FramebufferTarget target) {
#endif
// P2 step e4. On the {slot, gen} arm this is an ORDINARY read of pushed state and the cache
// above is not consulted, which closes the P1 accessor bypass: the cached raw pointer ran
// the checked accessor once per context change and then handed out the pointee forever, so
// at all five call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify
// read-hook (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills
// GetFramebufferBindingSlot could not be caught here, and a verify build compared the field
// only where the slow accessor was used.
//
// The cache stays on the LEGACY arm, gated on the same subsystem bit as the rest of this
// slice, so that MOBILEGL_PIPE_PUSH=0 keeps being the faithful all-subsystems-pull control
// ConfigLoader.cpp documents - "reproduces P1's behaviour exactly" has to include this
// path, or the integrator's A/B measures e4 on both arms and attributes it to neither. In
// the pull build MGB_CTX is the live GLContext, there is no poison to bypass and the
// frontend getter still linear-scans, so the cache is exactly the code it was.
static inline FbBindingSlot& GetFramebufferBindingSlotChecked(FramebufferTarget target) {
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
return MGB_CTX->GetFramebufferBindingSlot(target);
}
#endif
#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS
const void* ctx = MGB_CTX_IDENTITY;
if (ctx != g_fbSlotCacheContext) {
auto& live = *MGB_CTX;
@@ -154,6 +186,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fbSlotCacheContext = ctx;
}
return *g_fbSlotCache[SizeT(target)];
#else
// No legacy arm compiled: EsprytSlotTablesEnabled() is unconditionally true above.
return MGB_CTX->GetFramebufferBindingSlot(target);
#endif
}
static Bool IsDualSourceBlendFactor(BlendFactor v) {
@@ -465,7 +501,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
for (SizeT i = 0; i < bindingPointCnt; ++i) {
const auto& obj =
MGB_CTX->GetBufferBindingPoint(BufferTarget::ShaderStorage, i).GetBoundObject();
#if MOBILEGL_PIPE_PUSH
// P3a (D-D): announced on the reverse channel on the handle arm, poked into
// the object on the legacy one. MarkBufferGpuWritten is the one place that
// decides, so the three announcement sites stay one line each.
MarkBufferGpuWritten(obj);
#else
if (obj) obj->MarkGpuWritten();
#endif
}
}
@@ -509,7 +552,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// conformance case reads the result back with glMapBufferRange or
// glGetBufferSubData - which serve the frontend's CPU shadow until the buffer is
// flagged (BufferObject::SyncGpuWrites), exactly as for a storage buffer.
#if MOBILEGL_PIPE_PUSH
MarkBufferGpuWritten(obj);
#else
obj->MarkGpuWritten();
#endif
}
}
@@ -531,6 +578,112 @@ namespace MobileGL::MG_Backend::DirectGLES {
BindBufferId(glTarget, backendResource->id);
}
#if MOBILEGL_PIPE_PUSH
// The handle arm of the resolved-draw-buffers memo (D-G4). Two substitutions and
// nothing else:
//
// validity the frontend VAO's wrapping configuration version is replaced by the
// bound vertex-elements CSO ({slot, gen} AND its server-owned content
// serial) plus the vertex-buffer set's own serial - so an emission the
// suppressor let through is what re-opens the memo, not a counter the
// backend reads out of the frontend;
// identity an entry names its resource by handle and the clean probe compares that,
// instead of a raw frontend address that a successor object can reproduce.
//
// The WALK itself still reads the frontend VAO's attributes, and deliberately: the
// buffers a draw needs ensured is a pull site P3a does not migrate (dirty bits 15-17
// are P4b's and the resolution move is P8's), and EnsureBufferResource still owes
// BufferObject::SyncPersistentMappedRange one call (D-N).
void SyncVaoAttributeBuffersByHandle(const SharedPtr<MG_State::GLState::VertexArrayObject>& currentVAOObject,
VertexArrayImpl::BackendVertexArrayObject::ResolvedDrawBuffers* memo,
Uint64 bufferEpoch) {
const auto& st = MG_Pipe::MGPipeApplier();
const MG_Pipe::MGPipeHandle elements = st.BoundVertexElements;
Uint64 elementsSerial = 0;
Bool haveElementsRecord = false;
if (!MG_Pipe::MGPipeHandleIsNull(elements) && elements.Slot < st.VertexElementsCsos.size()) {
const auto& record = st.VertexElementsCsos[elements.Slot];
if (record.Live && record.Gen == elements.Gen) {
elementsSerial = record.ContentSerial;
haveElementsRecord = true;
}
}
const Uint64 buffersSerial = st.VertexBuffersSerial;
// NO LIVE ELEMENTS RECORD IS A MISS, NEVER A HIT. With none, the key above is
// {null, 0, buffersSerial} - a key that describes no configuration at all and that
// NEVER CHANGES while the state stays that way, so a memo stamped with it would hit
// on every later draw of a VAO whose attributes have moved. That state is reachable:
// MGPipeApplierReset() empties VertexElementsCsos and BoundVertexElements at every
// change of the current context, and the client re-emits only at its next
// create/bind. The legacy arm's configuration version caught exactly this by moving.
const Bool memoKeyIsMeaningful = haveElementsRecord;
if (memo && memo->valid && memoKeyIsMeaningful && memo->elementsHandle == elements &&
memo->elementsSerial == elementsSerial && memo->buffersSerial == buffersSerial) {
if (memo->vboCleanEpoch != bufferEpoch) {
Bool allClean = true;
for (Uint i = 0; i < memo->count; ++i) {
auto& entry = memo->entries[i];
// entry.frontend is the same object the legacy arm probes and is kept
// alive by the VAO attribute's SharedPtr for as long as this memo is
// valid; it answers the live-map question no record carries in P3a.
if (IsBufferDrawCleanByHandle(entry.handle, entry.resource, entry.frontend)) continue;
allClean = false;
entry.resource =
EnsureBufferResource(currentVAOObject->GetAttribute(entry.attribIndex).Buffer);
}
memo->vboCleanEpoch = allClean ? bufferEpoch : 0;
}
return;
}
// Full walk, once per distinct buffer, rebuilding the memo as it goes.
MG_State::GLState::BufferObject* syncedBuffers[MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS];
Uint syncedBufferCount = 0;
const auto& allAttributes = currentVAOObject->GetAllAttributes();
for (Uint attribIndex = 0; attribIndex < allAttributes.size(); ++attribIndex) {
const auto& attrib = allAttributes[attribIndex];
if (!attrib.Enabled) continue;
const auto& bufferObject = attrib.Buffer;
if (!bufferObject) continue;
auto* const bufferKey = bufferObject.get();
Bool alreadySynced = false;
for (Uint i = 0; i < syncedBufferCount; ++i) {
if (syncedBuffers[i] == bufferKey) {
alreadySynced = true;
break;
}
}
if (alreadySynced) continue;
auto* resource = EnsureBufferResource(bufferObject);
if (memo) {
auto& entry = memo->entries[syncedBufferCount];
entry.frontend = bufferKey;
entry.attribIndex = static_cast<Uint8>(attribIndex);
entry.resource = resource;
entry.handle = HandleOfBuffer(bufferKey);
}
syncedBuffers[syncedBufferCount++] = bufferKey;
}
if (memo) {
memo->count = syncedBufferCount;
memo->elementsHandle = elements;
memo->elementsSerial = elementsSerial;
memo->buffersSerial = buffersSerial;
// Only a key that describes a real configuration is worth remembering; see
// memoKeyIsMeaningful above. The walk still ran and the buffers are ensured -
// this only refuses to let the NEXT draw skip it.
memo->valid = memoKeyIsMeaningful;
// Rebuilt via EnsureBufferResource, not probed clean: the next probe pass
// stamps the epoch.
memo->vboCleanEpoch = 0;
}
}
#endif // MOBILEGL_PIPE_PUSH
// `vaoConfigVersion` is the caller's early read of currentVAOObject->GetConfigVersion():
// the VAO's config fields live on a cache line the draw path touches nowhere else, and
// cycling section VAOs makes that a guaranteed miss - reading it at the top of
@@ -570,6 +723,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
const Uint64 bufferEpoch = CurrentBufferMutationEpoch();
auto* memo = vaoTwin ? &vaoTwin->GetResolvedDrawBuffersMemo() : nullptr;
const Uint32 configVersion = vaoConfigVersion;
#if MOBILEGL_PIPE_PUSH
if (VertexInputSubsystemEnabled()) {
SyncVaoAttributeBuffersByHandle(currentVAOObject, memo, bufferEpoch);
} else
#endif
if (memo && memo->valid && memo->configVersion == configVersion) {
if (memo->vboCleanEpoch != bufferEpoch) {
Bool allClean = true;
@@ -640,6 +798,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
// rebind another buffer with no epoch (and no config-version) move,
// so the identity compare always runs; only the clean PROBE is
// elided while the stamp holds.
#if MOBILEGL_PIPE_PUSH
if (ResourceSubsystemEnabled()) {
// Same three cases, with the identity re-keyed off the raw frontend
// address onto the resource's {slot, gen} (D-G4).
const MG_Pipe::MGPipeHandle iboHandle = HandleOfBuffer(possibleIBO.get());
if (memo && memo->iboHandle == iboHandle && memo->iboCleanEpoch == bufferEpoch) {
// probed fully clean at this epoch; nothing can have dirtied it
} else if (memo && memo->iboHandle == iboHandle &&
IsBufferDrawCleanByHandle(iboHandle, memo->iboResource,
possibleIBO.get())) {
memo->iboCleanEpoch = bufferEpoch;
} else {
auto* resource = EnsureBufferResource(possibleIBO);
if (memo) {
memo->iboHandle = iboHandle;
memo->iboFrontend = possibleIBO.get();
memo->iboResource = resource;
// Repaired, not probed clean: stamp on the next clean probe.
memo->iboCleanEpoch = 0;
}
}
} else
#endif
if (memo && memo->iboFrontend == possibleIBO.get() && memo->iboCleanEpoch == bufferEpoch) {
// probed fully clean at this epoch; nothing can have dirtied it
} else if (memo && memo->iboFrontend == possibleIBO.get() &&
@@ -1197,12 +1378,29 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the result to the buffer sync (resolved-buffers memo host), the VAO sync and
// the draw-time bind, which each used to run their own registry Find. The raw
// pointer stays valid for the whole draw: the frontend VAO is pinned by the
// context binding, and a live object's registry entry is never erased nor its
// twin replaced (see TwinLookupMemo's contract).
// context binding, and a live object's twin is never erased nor replaced - on the
// legacy arm that is TwinLookupMemo's contract, and on the {slot, gen} arm it is
// simply that nothing but the sweep frees a slot and the sweep only takes slots
// whose frontend object is already gone.
BackendVertexArrayObject* ResolveVaoTwin(const SharedPtr<MG_State::GLState::VertexArrayObject>& vao) {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
// No memo on this arm. The memo existed to turn the registry's hash probe into
// an array index; the slot table's Find already IS the array index, and the
// memo's whole safety argument - owner-equality against a recycled heap address
// - is answered by {slot, gen} instead of re-derived per lookup.
auto* slot = g_backendVertexArrayObjects.Find(vao.get());
auto& backendObj = slot ? *slot : g_backendVertexArrayObjects.GetOrCreate(vao);
if (!backendObj) {
backendObj = MakeShared<BackendVertexArrayObject>();
}
return backendObj.get();
}
#endif
#if MOBILEGL_PIPE_LEGACY_MEMOS
if (auto* twin = g_vaoTwinLookupMemo.Lookup(vao)) {
return twin;
}
@@ -1213,6 +1411,9 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
g_vaoTwinLookupMemo.Store(vao, backendObj.get());
return backendObj.get();
#else
return nullptr;
#endif
}
void SyncCurrentVAO(const SharedPtr<MG_State::GLState::VertexArrayObject>& currentVAOObject,
@@ -1317,6 +1518,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
// relocate every entry - leaving the reference dangling. Holding the object itself
// keeps the calls below working on the right twin regardless; the slot is re-resolved
// at the end for the reference this function returns.
//
// The slot-table arm has no such hazard - an entry is an array element and a nested
// insert can only reallocate the vector, which the re-resolve at the tail already
// handles - so the copy is a refcount it does not need to pay. It keeps the copy for
// exactly one thing: holding the twin alive across the nested sync.
const SharedPtr<BackendTextureObject> backendObj = backendSlot;
if (imageBindableStorageRequired) {
@@ -1337,6 +1543,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
backendObj->SyncBuiltinSamplerToBackend(textureObject);
}
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
// One re-resolve, and only because a nested GetOrCreate may have GROWN the
// vector and moved the element; the entry itself cannot have been erased, since
// nothing on this arm erases a live slot. No second Find-or-create, no
// put-the-twin-back repair.
auto* slot = g_backendTextureObjects.Find(textureObject.get());
MOBILEGL_ASSERT(slot != nullptr && *slot != nullptr,
"the texture twin resolved at entry is gone after its own sync");
if (slot != nullptr && *slot != nullptr) {
return *slot;
}
// Cannot happen - the caller holds the frontend object, so its slot cannot be
// reclaimed underneath this call - but the return is a reference, and a null
// deref in a release build is a worse way to learn that than a re-created twin.
auto& repaired = g_backendTextureObjects.GetOrCreate(textureObject);
if (!repaired) {
repaired = backendObj;
}
return repaired;
}
#endif
auto* refreshedSlot = g_backendTextureObjects.Find(textureObject.get());
auto& refreshedBackendObj = refreshedSlot ? *refreshedSlot
: g_backendTextureObjects.GetOrCreate(textureObject);
@@ -1363,6 +1591,108 @@ namespace MobileGL::MG_Backend::DirectGLES {
// * everything unit bindings say nothing about: the touched-unit high-water mark,
// the frontend context identity, the backend ES context generation, and (for the
// resolution memo) the program keys that arbitrate aliased targets.
#if MOBILEGL_PIPE_PUSH
// P2: the snapshot stops holding weak_ptrs and holds the frontend objects' LIFETIME IDs.
// The weak_ptr was here for one reason - a raw address lies once the allocator recycles
// it - and a lifetime id is a monotone per-class counter that is never handed out twice,
// so it answers the same question with an integer compare and without pinning a control
// block. 0 means "nothing bound", which no live object can collide with (the counters
// start at 1). This is not the twin table's {slot, gen}: a bound texture that has never
// been synced has no twin and therefore no handle, so a handle-keyed snapshot would read
// two never-synced textures as equal. The identity has to exist before the twin does.
//
// Split by the RUNTIME arm, not by the build, for exactly the reason g_fbSlotCache is:
// MOBILEGL_PIPE_PUSH=0 has to reproduce P1's behaviour (ConfigLoader.cpp), and a
// legacy-arm run that debounced on lifetime ids would be running P2's mechanism while
// the A/B attributed the result to P1. 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 keeping the
// legacy fields costs that arm nothing but the words, and gives the control back its
// fidelity. A build with no legacy arm compiled carries neither the fields nor the
// branch.
struct UnitBindingsSnapshot {
Array<Uint64, (SizeT)TextureTarget::TextureTargetCount> slotObjects{};
Uint64 samplerObject = 0;
#if MOBILEGL_PIPE_LEGACY_MEMOS
// P1's identity, kept verbatim for the legacy arm only.
Array<WeakPtr<MG_State::GLState::ITextureObject>, (SizeT)TextureTarget::TextureTargetCount>
legacySlotObjects{};
WeakPtr<MG_State::GLState::SamplerObject> legacySamplerObject{};
#endif
};
static Uint64 LifetimeIdOf(const SharedPtr<MG_State::GLState::ITextureObject>& object) {
return object ? object->GetLifetimeId() : 0;
}
static Uint64 LifetimeIdOf(const SharedPtr<MG_State::GLState::SamplerObject>& object) {
return object ? object->GetLifetimeId() : 0;
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
#define MGB_UNIT_BINDINGS_HANDLE_ARM (EsprytSlotTablesEnabled())
#else
#define MGB_UNIT_BINDINGS_HANDLE_ARM (true)
#endif
static void CaptureUnitBindings(Int maxTouchedUnit, Vector<UnitBindingsSnapshot>& out) {
const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM;
out.resize(static_cast<SizeT>(maxTouchedUnit + 1));
for (Int unit = 0; unit <= maxTouchedUnit; ++unit) {
auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit);
auto& snapshot = out[static_cast<SizeT>(unit)];
const auto& slots = textureUnit.GetAllBindingSlots();
for (SizeT i = 0; i < slots.size(); ++i) {
if (handleArm) {
snapshot.slotObjects[i] = LifetimeIdOf(slots[i].GetBoundObject());
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
else {
snapshot.legacySlotObjects[i] = slots[i].GetBoundObject();
}
#endif
}
if (handleArm) {
snapshot.samplerObject = LifetimeIdOf(textureUnit.GetSamplerObject());
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
else {
snapshot.legacySamplerObject = textureUnit.GetSamplerObject();
}
#endif
}
}
static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector<UnitBindingsSnapshot>& snapshots) {
if (snapshots.size() != static_cast<SizeT>(maxTouchedUnit + 1)) return false;
const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM;
for (Int unit = 0; unit <= maxTouchedUnit; ++unit) {
auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit);
const auto& snapshot = snapshots[static_cast<SizeT>(unit)];
const auto& slots = textureUnit.GetAllBindingSlots();
for (SizeT i = 0; i < slots.size(); ++i) {
if (handleArm) {
if (snapshot.slotObjects[i] != LifetimeIdOf(slots[i].GetBoundObject())) return false;
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
else if (!OwnerEquals(snapshot.legacySlotObjects[i], slots[i].GetBoundObject())) {
return false;
}
#endif
}
if (handleArm) {
if (snapshot.samplerObject != LifetimeIdOf(textureUnit.GetSamplerObject())) return false;
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
else if (!OwnerEquals(snapshot.legacySamplerObject, textureUnit.GetSamplerObject())) {
return false;
}
#endif
}
return true;
}
#undef MGB_UNIT_BINDINGS_HANDLE_ARM
#else
struct UnitBindingsSnapshot {
Array<WeakPtr<MG_State::GLState::ITextureObject>, (SizeT)TextureTarget::TextureTargetCount>
slotObjects{};
@@ -1395,6 +1725,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
return true;
}
#endif
static Vector<UnitBindingsSnapshot> g_observedUnitBindings;
static Uint64 g_observedUnitBindingsContextId = 0;
@@ -1610,7 +1941,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// registry keeps a backend object alive until its frontend texture expires, which an
// attached texture cannot. A renderbuffer-only FBO - the common Minecraft frame -
// reduces to the key compare and an empty loop.
const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw);
const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw);
const auto& currentFBO = drawSlot.GetBoundObject();
if (currentFBO) {
const Uint16 fboSlotVersion = drawSlot.GetVersion();
@@ -1827,7 +2158,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
auto* textureBuffer =
static_cast<MG_State::GLState::TextureObjectBuffer*>(imageBinding.Texture.get());
const auto& bufferObject = textureBuffer->GetBufferBindingSlot().GetBoundObject();
#if MOBILEGL_PIPE_PUSH
BufferImpl::MarkBufferGpuWritten(bufferObject);
#else
if (bufferObject) bufferObject->MarkGpuWritten();
#endif
}
}
@@ -1904,7 +2239,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
MG_State::GLState::FramebufferObject* lastUpdatedFBO = nullptr;
for (auto& target : fboTargets) {
auto& slot = GetFramebufferBindingSlotFast(target);
auto& slot = GetFramebufferBindingSlotChecked(target);
auto& currentFBO = slot.GetBoundObject();
// The three memos together say "this target is already synced": which object is
@@ -2742,7 +3077,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// only runs later in PrepareForDraw: a program compiled against a stale count
// would not be relinked until the draw after the one that needed it.
{
const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw);
const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw);
const auto& drawFBO = drawSlot.GetBoundObject();
const Uint16 slotVersion = drawSlot.GetVersion();
const Uint16 objectVersion = drawFBO ? drawFBO->GetObjectVersion() : 0;
@@ -2766,16 +3101,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_fragColorBroadcastCount = g_broadcastMemoCount;
}
BackendProgramObjectImpl* twin = g_programTwinLookupMemo.Lookup(currentProgram);
if (!twin) {
auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get());
auto& backendObj =
backendProgramSlot ? *backendProgramSlot : g_backendProgramObjects.GetOrCreate(currentProgram);
BackendProgramObjectImpl* twin = nullptr;
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
auto* slot = g_backendProgramObjects.Find(currentProgram.get());
auto& backendObj = slot ? *slot : g_backendProgramObjects.GetOrCreate(currentProgram);
if (!backendObj) {
backendObj = MakeShared<BackendProgramObjectImpl>();
}
g_programTwinLookupMemo.Store(currentProgram, backendObj.get());
twin = backendObj.get();
} else
#endif
{
#if MOBILEGL_PIPE_LEGACY_MEMOS
twin = g_programTwinLookupMemo.Lookup(currentProgram);
if (!twin) {
auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get());
auto& backendObj = backendProgramSlot ? *backendProgramSlot
: g_backendProgramObjects.GetOrCreate(currentProgram);
if (!backendObj) {
backendObj = MakeShared<BackendProgramObjectImpl>();
}
g_programTwinLookupMemo.Store(currentProgram, backendObj.get());
twin = backendObj.get();
}
#endif
}
// A link-version mismatch means the program was relinked: the backend
// shaders and every cache built by CacheResourceLocations (block
@@ -2857,7 +3207,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
auto& slot = GetFramebufferBindingSlotFast(target);
auto& slot = GetFramebufferBindingSlotChecked(target);
// No fast path on the binding slot's version. It is a 16-bit counter that only
// ForceBindCurrentFBO ever stamps here, so the comparison was against an arbitrarily old
// snapshot and any later slot version that happened to land on it - one wrap of the
@@ -2871,13 +3221,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
// and the twin memo replaces even that with an array probe on the steady path.
const auto& currentFBO = slot.GetBoundObject();
if (currentFBO && currentFBO != MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) {
FramebufferImpl::BackendFramebufferObject* twin = g_fboTwinLookupMemo.Lookup(currentFBO);
if (!twin) {
auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get());
if (backendFBOSlot && *backendFBOSlot) {
twin = backendFBOSlot->get();
g_fboTwinLookupMemo.Store(currentFBO, twin);
FramebufferImpl::BackendFramebufferObject* twin = nullptr;
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
// Not "slot": the enclosing scope's `slot` is the framebuffer BINDING slot,
// and this one is the twin table's entry.
auto* twinEntry = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get());
if (twinEntry && *twinEntry) {
twin = twinEntry->get();
}
} else
#endif
{
#if MOBILEGL_PIPE_LEGACY_MEMOS
twin = g_fboTwinLookupMemo.Lookup(currentFBO);
if (!twin) {
auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get());
if (backendFBOSlot && *backendFBOSlot) {
twin = backendFBOSlot->get();
g_fboTwinLookupMemo.Store(currentFBO, twin);
}
}
#endif
}
if (twin) {
twin->Bind(target);
@@ -2932,7 +3297,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
#ifdef TRACY_ENABLE
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
auto& slot = GetFramebufferBindingSlotFast(target);
auto& slot = GetFramebufferBindingSlotChecked(target);
const auto& fbo = slot.GetBoundObject();
SyncAndBindFramebufferObject(fbo, target);
FramebufferImpl::g_fboSyncedSlotVersions[(SizeT)target] = slot.GetVersion();
@@ -3155,7 +3520,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
// pass creates it later in the same draw), and a cached miss would keep skipping the
// bind after it appears.
struct UnitSamplerLookupMemo {
#if MOBILEGL_PIPE_PUSH
// The {slot, gen} of the frontend sampler this row was resolved for. It replaces the
// weak_ptr and its owner compare: a stale row cannot match, because the successor of a
// freed sampler is handed the same slot only with a higher Gen.
MG_Pipe::MGPipeHandle frontendHandle = MG_Pipe::kMGPipeNullHandle;
#endif
#if MOBILEGL_PIPE_LEGACY_MEMOS
WeakPtr<MG_State::GLState::SamplerObject> frontend{};
#endif
SamplerImpl::BackendSamplerObject* backend = nullptr;
};
static Array<UnitSamplerLookupMemo, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
@@ -3164,6 +3537,25 @@ namespace MobileGL::MG_Backend::DirectGLES {
static SamplerImpl::BackendSamplerObject* ResolveUnitSamplerBackend(
Int unit, const SharedPtr<MG_State::GLState::SamplerObject>& samplerObject) {
auto& memo = g_unitSamplerLookupMemos[static_cast<SizeT>(unit)];
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
const MG_Pipe::MGPipeHandle handle =
SamplerImpl::g_backendSamplerObjects.HandleOf(samplerObject.get());
if (memo.backend && !MG_Pipe::MGPipeHandleIsNull(handle) && memo.frontendHandle == handle) {
return memo.backend;
}
auto* slot = SamplerImpl::g_backendSamplerObjects.FindByHandle(handle);
if (slot && *slot) {
// A MISS is still never cached: the twin may not exist yet when the unit pass
// runs, because the program pass creates it later in the same draw.
memo.frontendHandle = handle;
memo.backend = slot->get();
return memo.backend;
}
return nullptr;
}
#endif
#if MOBILEGL_PIPE_LEGACY_MEMOS
if (memo.backend && OwnerEquals(memo.frontend, samplerObject)) {
return memo.backend;
}
@@ -3173,6 +3565,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
memo.backend = backendSamplerSlot->get();
return memo.backend;
}
#endif
return nullptr;
}
@@ -4885,14 +5278,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
// The emulated shift has to be in place before PrepareForDraw, because that is what syncs the
// VAO; a zero here is what un-shifts the arrays for the next ordinary draw.
//
// P3a (D-H2): this is the LEGACY arm's carrier. On the handle arm the draw's raw base
// instance rides in MGPVertexBuffers::BaseInstance - a ContentHash input, so a base-instance
// change that moves no buffer is still emitted rather than suppressed - and the server
// decides whether to shift, because emulation ownership is the server's. The scopes below
// are therefore compiled only where the pre-handle arm is (a pull build always).
inline Uint32 EmulatedFetchBaseInstance(GLuint baseinstance) {
return UseNativeBaseInstance() ? 0u : static_cast<Uint32>(baseinstance);
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
#define MGL_SCOPED_FETCH_BASE_INSTANCE(name, value) \
const VertexArrayImpl::ScopedFetchBaseInstance name(value)
#else
#define MGL_SCOPED_FETCH_BASE_INSTANCE(name, value) ((void)(value))
#endif
void DrawElementsInstancedBaseVertexBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices,
GLsizei instancecount, GLint basevertex, GLuint baseinstance) {
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing;
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance));
PrepareForDraw(syncBit);
const ScopedRestartIndexSubstitution restart(type, count, indices);
if (!restart.DrawIsValid()) return;
@@ -4929,7 +5335,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void DrawElementsInstancedBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices,
GLsizei instancecount, GLuint baseinstance) {
DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing;
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance));
PrepareForDraw(syncBit);
const ScopedRestartIndexSubstitution restart(type, count, indices);
if (!restart.DrawIsValid()) return;
@@ -4981,7 +5387,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void DrawArraysInstancedBaseInstance(GLenum mode, GLint first, GLsizei count, GLsizei instancecount,
GLuint baseinstance) {
DrawSyncFlags syncBit = DrawSyncBit::Instancing;
const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance));
MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance));
PrepareForDraw(syncBit);
SetCurrentBaseInstance(baseinstance);
ForEachViewportRoutingPass([&] {
@@ -6410,6 +6816,86 @@ namespace MobileGL::MG_Backend::DirectGLES {
}
const GLuint backendTextureId = (*backendTextureSlot)->GetBackendTextureId();
// The one direct-iteration site over a twin table. The legacy walk reads the map
// KEY, i.e. the raw frontend address, and has to test the entry's weak_ptr by hand
// before it dares dereference it. ForEachLive hands over a strong reference instead,
// so that hazard cannot arise; the body is otherwise identical, which is why it is
// lifted into a lambda both arms call.
#if MOBILEGL_PIPE_PUSH
// The push arm walks the twin table through ForEachLive, which hands over a
// STRONG reference to the state object; the legacy map walk below it reads the
// map key - the raw frontend address - and has to test the entry weak_ptr by
// hand first. The body is shared between the two arms through the lambda.
const auto detachFrom = [&](MG_State::GLState::FramebufferObject* stateFBO,
const SharedPtr<FramebufferImpl::BackendFramebufferObject>& backendFBO) {
if (stateFBO == nullptr || !backendFBO || stateFBO->IsDefaultFramebuffer()) {
return;
}
const auto& attachments = stateFBO->GetAllAttachmentObjects();
for (SizeT i = 0; i < attachments.size(); ++i) {
const auto& attachmentObject = attachments[i];
if (!attachmentObject.IsTexture() || attachmentObject.GetTexture().get() != texture.get()) {
continue;
}
const auto frontendType = static_cast<FramebufferAttachmentType>(i);
GLenum backendAttachment = GL_NONE;
if (frontendType >= FramebufferAttachmentType::Color0 &&
frontendType <= FramebufferAttachmentType::Color31) {
backendAttachment = backendFBO->GetBackendAttachmentType(frontendType);
} else {
backendAttachment = MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(frontendType);
}
if (backendAttachment == GL_NONE || backendAttachment == GL_UNKNOWN_MGL) {
continue;
}
GLenum textureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum(
attachmentObject.GetTextureUploadTarget());
if (textureTarget == GL_UNKNOWN_MGL) {
textureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(texture->GetTarget());
}
const GLuint backendFBOId = backendFBO->GetBackendFramebufferId();
FramebufferImpl::BindFramebufferId(GL_DRAW_FRAMEBUFFER, backendFBOId);
if (attachmentObject.IsLayered()) {
g_GLESFuncs.glFramebufferTexture(GL_DRAW_FRAMEBUFFER, backendAttachment, 0, 0);
} else {
g_GLESFuncs.glFramebufferTexture2D(
GL_DRAW_FRAMEBUFFER, backendAttachment, textureTarget, 0, 0);
}
ClearGLErrors();
m_detachedAttachments.push_back(
{backendFBOId, backendAttachment, textureTarget, backendTextureId,
static_cast<GLint>(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()});
}
};
// Already inside #if MOBILEGL_PIPE_PUSH, so no second guard here: the arm choice
// below is the RUNTIME one.
if (EsprytSlotTablesEnabled()) {
FramebufferImpl::g_backendFramebufferObjects.ForEachLive(
[&](const SharedPtr<MG_State::GLState::FramebufferObject>& stateFBO,
const SharedPtr<FramebufferImpl::BackendFramebufferObject>& backendFBO) {
detachFrom(stateFBO.get(), backendFBO);
});
return;
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin();
it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) {
// An entry whose state object died is only waiting for the next collection;
// the key is a dangling address, so it must not be dereferenced here.
if (it->second.stateRef.expired()) {
continue;
}
detachFrom(it->first, it->second.backend);
}
#endif
#else
// Pull build: exactly the pre-P2 walk, so this translation unit generates the
// same code it did before P2 (G1).
for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin();
it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) {
auto* stateFBO = it->first;
@@ -6460,6 +6946,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
static_cast<GLint>(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()});
}
}
#endif
}
~ScopedDetachedTextureFramebufferAttachments() {
@@ -9901,6 +10388,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
static Bool InitDisplayAndContext(EGLint surfaceBit, NativeWindowType window = static_cast<NativeWindowType>(0)) {
DestroyEGLContext();
#if MOBILEGL_PIPE_PUSH
// DIAGNOSE the twin-table arm here, at backend startup, so an operator who set
// MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS into a combination that leaves no
// arm at all is told so by name, in the log, before the first draw.
//
// Diagnose, and deliberately NOT resolve: resolving raises
// Fatal{PipeLegacyMemosDisabled}, and this function runs inside eglMakeCurrent, which
// the integration harness pre-flights in a FORKED CHILD
// (MG_IntegrationTest/Harness/HeadlessGL.cpp). A child that dies on a signal is reported
// to the parent as "no usable GPU/display/ICD" and every scenario in the lane is
// SKIPPED - so the stop became a green lane that ran nothing, on exactly the two env
// vars the D14/D18 A/B is driven with (ROADMAP.md:7). The stop now belongs to the first
// twin lookup, which happens in a scenario body where a crash IS a test failure.
DiagnoseEsprytSlotArm();
#endif
g_Display = g_EGLFuncs.eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (g_Display == EGL_NO_DISPLAY) return false;
File diff suppressed because it is too large Load Diff
+413 -16
View File
@@ -16,6 +16,11 @@
#include <MG_State/GLState/TextureState/TextureObject.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include "SlotTables.h"
#if MOBILEGL_PIPE_PUSH
// P3a: the vertex-input payload views the handle arm of the VAO twin consumes.
#include <MG_Pipe/MGPipeTypes.h>
#endif
namespace MobileGL::MG_Backend::DirectGLES {
String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType);
@@ -267,7 +272,35 @@ namespace MobileGL::MG_Backend::DirectGLES {
EndViewportRoutingPasses(passCount);
}
template <typename StateObject, typename BackendObject>
// The backend twin table. Two arms live behind this one interface (ARCHITECTURE.md 9.6 -
// after Track H the MOBILEGL_PIPE_PUSH bitmap alone is not a valid A/B, because with a bit
// clear the backend would still be running the re-keyed code):
//
// legacy (MOBILEGL_PIPE_LEGACY_MEMOS): UnorderedMap<StateObject*, Entry> keyed on the
// frontend heap ADDRESS, with a weak_ptr per entry as the ABA defence, an erase
// inside Find, and a garbage sweep as the only death signal. Pre-P2 code verbatim.
// handles (MOBILEGL_PIPE_PUSH and kMGPipeSubsystemEsprytSlots): BackendSlotTable, keyed
// on MGPipeHandle{Slot, Gen}. See SlotTables.h for what that buys.
//
// Which arm runs is fixed once per process (EsprytSlotTablesEnabled()): the two arms hold
// their twins in different containers, so a mid-run flip would strand every twin already
// built. Every call site below this class is arm-agnostic and unchanged.
//
// The kind is a template parameter ONLY in the push build. G1 requires the pull build's
// symbol set to be byte-for-byte the pre-P2 one, and a third template argument changes
// every instantiation's mangled name - so in the pull build the parameter, like the arm it
// selects, does not exist. The macro below spells that one difference; it is #undef'd
// straight after the class, and the twelve declaration and definition sites name the
// registry through the TwinRegistry alias instead, which swallows the kind in the pull
// build. (An alias template may have a parameter it does not use, and an alias emits no
// symbol of its own, so the pull build's mangled names are unchanged.)
#if MOBILEGL_PIPE_PUSH
#define MGB_TWIN_KIND_PARAM , MG_Pipe::MGPipeKind kKind
#else
#define MGB_TWIN_KIND_PARAM
#endif
template <typename StateObject, typename BackendObject MGB_TWIN_KIND_PARAM>
class StateBackendObjectRegistry {
public:
@@ -286,12 +319,26 @@ namespace MobileGL::MG_Backend::DirectGLES {
using BackendMap = UnorderedMap<StateObject*, Entry>;
using iterator = typename BackendMap::iterator;
using const_iterator = typename BackendMap::const_iterator;
#if MOBILEGL_PIPE_PUSH
using SlotTable = BackendSlotTable<StateObject, BackendObject, kKind>;
#endif
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null");
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
// The slot table arms the teardown sentinel itself, at its own first
// insertion (D13; SlotTables.h) - so a table used outside a registry arms
// it too, which is right: it is the twin, not the registry, that owns the
// driver id a guarded destructor exists for.
return m_slotTable.GetOrCreate(stateObj);
}
#endif
// Twin creation is the moment a driver-owned id starts needing a guarded
// destructor; cold path, so the once-guard costs nothing per draw.
// destructor; cold path, so the once-guard costs nothing per draw. It is armed
// here, at the first insertion - a destructor hook on the table itself is wrong
// for the reason spelled out above InProcessTeardown().
EnsureProcessTeardownSentinel();
// Sweep BEFORE the entry reference below exists: the map is open-addressed and an
// erase relocates the rest of the probe cluster, so collecting once that reference
@@ -324,14 +371,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
return entry.backend;
}
// Null when no live state object owns this key. The result points into the map, so
// it stays valid only until the next GetOrCreate/Find/CollectGarbage on this registry.
// Take that literally, including for Find: the map is open-addressed and erases by
// shifting the rest of the probe cluster into the hole, so an erase relocates entries
// OTHER than the erased one - and Find erases, whenever it lands on a key whose state
// object has expired. Callers that need the twin across another registry call must copy
// the BackendPtr out (or keep only the pointee, which is heap-allocated and never moves).
// Null when no live state object owns this key.
//
// On the HANDLE arm the result is a stable array element: only a GetOrCreate that grows
// the table can move it, and nothing else on the table invalidates it.
//
// On the LEGACY arm the result points into the map, so it stays valid only until the
// next GetOrCreate/Find/CollectGarbage on this registry. Take that literally, including
// for Find: the map is open-addressed and erases by shifting the rest of the probe
// cluster into the hole, so an erase relocates entries OTHER than the erased one - and
// Find erases, whenever it lands on a key whose state object has expired. Callers that
// need the twin across another registry call must copy the BackendPtr out (or keep only
// the pointee, which is heap-allocated and never moves).
BackendPtr* Find(StateObject* stateObj) {
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
return m_slotTable.Find(stateObj);
}
#endif
const auto entryIt = m_entries.find(stateObj);
if (entryIt == m_entries.end()) {
return nullptr;
@@ -352,16 +409,98 @@ namespace MobileGL::MG_Backend::DirectGLES {
iterator end() { return m_entries.end(); }
const_iterator end() const { return m_entries.end(); }
#if MOBILEGL_PIPE_PUSH
// The {slot, gen} this object's twin is keyed on, or the null handle. This is what a
// backend memo stores instead of a raw pointer, a GL name or a bare lifetime id.
MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const {
if (EsprytSlotTablesEnabled()) {
return m_slotTable.HandleOf(stateObj);
}
return MG_Pipe::kMGPipeNullHandle;
}
// The twin at a handle, or null when the slot is free or its Gen has moved on. This is
// the lookup a backend memo that already holds a handle wants: no lifetime-id probe.
BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) {
if (EsprytSlotTablesEnabled()) {
return m_slotTable.FindByHandle(handle);
}
return nullptr;
}
// P2 step e2. STATIC, because a death notice is about an object and not about a
// registry instance: it is answered by EVERY table of this kind that exists - this
// registry's own, and any by-value copy of it a fixture or a context reset is holding
// (SlotTables.h explains the holder list and why one holder was a leak).
//
// The legacy arm cannot answer this at all - its key is the frontend heap ADDRESS and
// the object is already gone by the time the notice arrives - so there it is a no-op
// and the garbage sweep stays its only death signal. That asymmetry is not an
// oversight: it is the A/B the compile-time arm exists to make measurable
// (ARCHITECTURE.md 9.6), and announced-versus-discovered death is one of the things
// being measured.
static Bool DestroyByLifetimeId(Uint64 lifetimeId) {
if (EsprytSlotTablesEnabled()) {
return SlotTable::OnFrontendObjectDestroyed(lifetimeId);
}
return false;
}
// fn(const StatePtr& state, const BackendPtr& twin) over every live entry. The legacy
// begin()/end() handed out the map key, i.e. the raw frontend address - exactly the
// identity the backend must stop reading - and handed it out for entries whose state
// object had already died, so the one caller had to test stateRef.expired() itself
// before dereferencing it. Here the state object arrives as a strong reference.
template <typename Fn>
void ForEachLive(Fn&& fn) const {
if (EsprytSlotTablesEnabled()) {
m_slotTable.ForEachLive(fn);
return;
}
for (const auto& [stateKey, entry] : m_entries) {
(void)stateKey;
if (!entry.backend) continue;
const StatePtr state = entry.stateRef.lock();
if (!state) continue;
fn(state, entry.backend);
}
}
#endif
// The seven DirectGLES.cpp call sites drive the LEGACY arm and nothing else. On the
// handle arm death is announced by the frontend object's destructor
// (MG_State/GLState/StateObjectDeathNotice.h), so there is no garbage to collect on a
// tick, the slot table has no collector to forward to, and this is the predicted
// branch plus a return - which is how ROADMAP.md:18's "delete the GC" is delivered
// without deleting the legacy arm's own collector while that arm is still compiled
// beside it.
void CollectGarbageIfNeeded() {
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
return;
}
#endif
#if MOBILEGL_PIPE_LEGACY_MEMOS
++m_gcTick;
if (m_gcTick < kGCInterval) {
return;
}
CollectGarbage();
m_gcTick = 0;
#endif
}
void CollectGarbageNow() { CollectGarbage(); }
// Pre-P2 API, kept for the legacy arm. On the handle arm there is nothing it could
// collect: a twin leaves with its object's death notice, and a notice dropped during
// process teardown is a deliberate leak (SlotTables.h), not garbage awaiting a call.
void CollectGarbageNow() {
#if MOBILEGL_PIPE_PUSH
if (EsprytSlotTablesEnabled()) {
return;
}
#endif
CollectGarbage();
}
private:
void CollectGarbage() {
@@ -395,8 +534,24 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 m_gcTick = 0;
Uint32 m_creationTick = 0;
Bool m_isCollecting = false;
#if MOBILEGL_PIPE_PUSH
BackendSlotTable<StateObject, BackendObject, kKind> m_slotTable;
#endif
};
#undef MGB_TWIN_KIND_PARAM
// One spelling for the twin registry at every declaration and definition site. In the push
// build the kind is the registry's third template argument; in the pull build the alias
// drops it, so the mangled name is the pre-P2 two-argument one.
#if MOBILEGL_PIPE_PUSH
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
using TwinRegistry = StateBackendObjectRegistry<StateObject, BackendObject, kKind>;
#else
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
using TwinRegistry = StateBackendObjectRegistry<StateObject, BackendObject>;
#endif
namespace BufferImpl {
const GLenum TempBufferTarget = GL_ARRAY_BUFFER;
@@ -493,8 +648,135 @@ namespace MobileGL::MG_Backend::DirectGLES {
// no map, and a respecification then has to retire the id rather than hand it
// to glBufferData, which the driver would silently refuse.
Bool immutableStorage = false;
#if MOBILEGL_PIPE_PUSH
// P3a: the client's shadow base as the last content-carrying resource call left
// it. The handle-shaped ops carry `shadow + offset` beside their record, so the
// base is recovered by subtracting the record's own offset once, here.
//
// IT IS A RAW POINTER INTO AN ALLOCATION THIS SIDE DOES NOT OWN, so its lifetime
// rule is written here and enforced at the three events that end it - a cached
// base with no invalidation is a use-after-free waiting for an ordinary call:
//
// * a content-carrying call (respecify / sub-data / flush-range) REFRESHES it;
// * an ORPHANING respecify (HasDefinedContent clear) CLEARS it, because that is
// also the call that resizes the shadow - reserve + resize reallocates and
// frees the old block - and it brings no replacement base;
// * a successful map_persistent CLEARS it, because the client then adopts the
// coherent pointer and drops the shadow (PipeResource::AdoptPersistentMap does
// clear() + shrink_to_fit()). For such a resource the bytes are persistentPtr.
//
// Every reader treats null as "no bytes to move". And any path that STILL HOLDS the
// frontend object - the ensure path does, because D-N keeps SyncPersistentMappedRange
// there for all of P3a - re-reads MappedData() instead of reading this, exactly as
// the legacy arm did; this member exists for the drains that have no object, which
// in P3a is the readback flush and the fp64 narrowing.
const Uint8* hostBytes = nullptr;
#endif
};
#if MOBILEGL_PIPE_PUSH
// P3a (D-A4): the SEVENTH Espryt slot table, and the first one keyed by a handle the
// CALL carried rather than one this backend minted off a frontend object's lifetime
// id. That is what discharges, for this kind, the debt SlotTables.h records against
// itself: GLESBufferResource stops hanging off PipeResource::m_backend and lives here
// instead, so the resource table is the server's own and a frontend heap reference is
// no longer part of resolving it.
//
// The StateObject parameter is BufferObject only because the template names one; not
// one member that touches it is instantiated on this table (no Find(StateObject*), no
// HandleOf, no ForEachLive), and the handle overloads never look at it. Death is
// announced by the family's own ResourceDestroy call, not by the shared death notice
// (D-L), and the slot is freed by the CLIENT after that call returns.
using BackendBufferResourceTable =
BackendSlotTable<MG_State::GLState::BufferObject, GLESBufferResource, MG_Pipe::MGPipeKind::Buffer>;
extern BackendBufferResourceTable g_backendBufferResources;
// Resolved once per process and latched, exactly like EsprytSlotTablesEnabled() and
// for the same reason: the two arms hold GLESBufferResource in DIFFERENT containers -
// the legacy arm in the frontend object's PipeResource::m_backend, the handle arm in
// the table above - so an answer that changed mid-run would strand every resource
// already built and leak the driver ids they own.
Bool ResolveResourceSubsystemArm();
// Same shape for the vertex-input family (bit 8), and separate because the two bits are
// separately clearable - but NOT independent, and the resolver says so out loud rather
// than half-running: bit 8 REQUIRES bit 7, because the vertex-input handle arm resolves
// every attribute's driver buffer id out of the resource slot table and only bit 7 puts
// twins there. `0x17f` (bit 8 on, bit 7 off) is therefore refused at arm resolution with
// a named MGLOG_E and runs the legacy vertex-input arm; `0x0ff` (bit 7 on, bit 8 off) is
// a real, supported A/B, because the legacy VAO walk reaches the handle arm through
// EnsureBufferResource's own dispatch. Both resolvers also answer
// MG_Config::Features.PipeLegacyMemos, so "the bit is clear and the legacy arm was taken
// away" is a named verdict instead of a silent legacy run.
Bool ResolveVertexInputSubsystemArm();
// INLINE for the reason SlotTables.h spells out at EsprytSlotTablesEnabled: both are
// consulted on the per-draw path (the VAO sync's gate, EnsureBufferResource, every
// buffer op), and out-of-line they would be a call through the PLT per consult.
inline Bool ResourceSubsystemEnabled() {
static const Bool enabled = ResolveResourceSubsystemArm();
return enabled;
}
inline Bool VertexInputSubsystemEnabled() {
static const Bool enabled = ResolveVertexInputSubsystemArm();
return enabled;
}
// Resolve-or-create / resolve-only, by the handle the call carried. Neither touches
// MGPipeSlots(): the handle ARRIVED already minted by the side that owns minting.
GLESBufferResource* GetOrCreateBufferResourceForHandle(MG_Pipe::MGPipeHandle res);
GLESBufferResource* FindBufferResourceForHandle(MG_Pipe::MGPipeHandle res);
// MONOLITH GLUE, and named as such: the handle of a resource this backend is looking
// at through a frontend object, resolved through the client allocator's lifetime-id
// index. Every caller is a site P3a deliberately does NOT migrate - the SSBO / UBO /
// indirect / pack-PBO binding walks are dirty bits 15-17 and P4b's, and the index
// host mirror is P8's - so they still arrive holding the object. Under a real split
// neither the object nor its lifetime id exists on this side and every one of them
// has to receive the handle in a payload instead.
MG_Pipe::MGPipeHandle HandleOfBuffer(const MG_State::GLState::BufferObject* bufferObject);
// The handle arms of the two draw-path entry points below. IsBufferDrawCleanByHandle
// asks the applier the same five questions IsBufferDrawClean asks the frontend object,
// with identical semantics (D-A4); EnsureBufferResourceForHandle is the ensure path
// driven by the applier's descriptor and the shadow base the call carried.
//
// `frontend` supplies the ONE question the applier's record cannot answer in P3a: an
// emulated (non-adopted) persistent map is written through its pointer with no call, so
// MGPipeResourceRecord::HasLiveHostWrites - the field that will carry it - is pinned
// false and the probe still has to ask the object. It retires with P5. See the long note
// at the definition; passing null means "no live map", not "unknown".
Bool IsBufferDrawCleanByHandle(MG_Pipe::MGPipeHandle res, const GLESBufferResource* resource,
const MG_State::GLState::BufferObject* frontend);
GLESBufferResource* EnsureBufferResourceForHandle(
const SharedPtr<MG_State::GLState::BufferObject>& bufferObject, MG_Pipe::MGPipeHandle res);
// P3a (D-D): "the GPU wrote through this resource", announced on the reverse channel
// instead of poked into the frontend object. ARCHITECTURE.md calls OnGpuWritten a
// NARROWING channel - the client builds its pending set conservatively at each
// draw/dispatch emission point and this callback only ever takes entries out of it -
// so in P3a, where the client's conservative set is exactly what the three
// MarkGpuWritten sites marked, the announced set is the whole resource and the
// observable behaviour is identical. P8/P9 narrow it; the channel is what they need.
//
// The legacy arm keeps calling BufferObject::MarkGpuWritten directly, and the pull
// build never sees this function at all (G1).
void MarkBufferGpuWritten(const SharedPtr<MG_State::GLState::BufferObject>& bufferObject);
// The applier's stored extent for this resource, 0 when it has no record. The one
// thing outside BufferImpl that needs it is the fp64 narrowing, whose source extent
// used to be BufferObject::GetSize().
SizeT ResourceWidthForHandle(MG_Pipe::MGPipeHandle res);
// The applier's server-owned mutation serial for this resource, 0 when it has no
// record. It is what the narrowed-fp64 memo keys its freshness on now that the
// frontend change serial is gone from the backend's view.
Uint64 ResourceSerialForHandle(MG_Pipe::MGPipeHandle res);
// The ES context generation a twin's driver id must carry to be current. Its one
// consumer is the draw-clean probe's unit test, which has to build a twin that answers
// CLEAN to every question except the one under test - a case that cannot go red for
// that question otherwise. Push-only, like the rest of this block.
Uint CurrentBufferContextGeneration();
#endif
// Registered as the frontend's BufferBackendOps at backend init and on
// every MakeCurrent (the ES context can be destroyed and recreated, e.g.
// by the trace replayer's probe context).
@@ -697,8 +979,31 @@ namespace MobileGL::MG_Backend::DirectGLES {
struct ResolvedDrawBuffers {
struct Entry {
MG_State::GLState::BufferObject* frontend = nullptr;
// A RAW TWIN POINTER, AND IT MAY DANGLE - the invariant that makes that safe
// is stated here rather than left in the two callers (espryt-v3 §8, m8).
//
// Nothing tells this memo when a twin dies: on the handle arm a
// resource_destroy takes the twin out of the slot table (ReleaseByHandle)
// while this entry still holds its address, and on the legacy arm the same
// is true of the registry's own release. So the rule is: THIS POINTER IS
// ONLY EVER DEREFERENCED AFTER THE ENTRY'S IDENTITY HAS BEEN RE-RESOLVED IN
// THE SAME PASS - FindByHandle(handle) on the handle arm, the frontend
// identity compare on the legacy one - and a miss re-resolves through
// EnsureBufferResource rather than trusting what is stored here. Both
// consumers do that today; a third one that read `resource` straight out of
// a "valid" memo would be reading freed memory, and no compare in this
// struct would catch it. The pointer stays raw because the alternative -
// owning a reference from a per-draw memo - is what keeps a dead driver
// buffer alive, which is the leak class P2's death notice exists to remove.
BufferImpl::GLESBufferResource* resource = nullptr;
Uint8 attribIndex = 0;
#if MOBILEGL_PIPE_PUSH
// P3a re-key: the entry's identity on the handle arm. A {slot, gen} cannot
// be reproduced by a recycled heap address, so the clean probe compares
// this instead of the raw frontend pointer and never has to ask the
// allocator for it again mid-draw.
MG_Pipe::MGPipeHandle handle = MG_Pipe::kMGPipeNullHandle;
#endif
};
Bool valid = false;
Uint32 configVersion = 0;
@@ -706,6 +1011,18 @@ namespace MobileGL::MG_Backend::DirectGLES {
Array<Entry, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS> entries;
MG_State::GLState::BufferObject* iboFrontend = nullptr;
BufferImpl::GLESBufferResource* iboResource = nullptr;
#if MOBILEGL_PIPE_PUSH
// P3a re-key of the memo's validity key: on the handle arm the frontend VAO's
// wrapping configuration version is replaced by the bound vertex-elements CSO
// (identity AND its server-owned content serial) plus the vertex-buffer set's
// own serial - three monotone Uint64s and a {slot, gen}, no wrap and no
// identity patch. The IBO entry keeps its separate key for the same reason it
// always had one: the index slot is not part of the configuration (D5).
MG_Pipe::MGPipeHandle elementsHandle = MG_Pipe::kMGPipeNullHandle;
Uint64 elementsSerial = 0;
Uint64 buffersSerial = 0;
MG_Pipe::MGPipeHandle iboHandle = MG_Pipe::kMGPipeNullHandle;
#endif
// Buffer-mutation epoch (BufferImpl::CurrentBufferMutationEpoch) at which
// the LAST probe pass found every entry / the IBO clean; 0 = not stamped
// (epochs start at 1). While a stamp matches the pre-pass epoch read, the
@@ -741,15 +1058,48 @@ namespace MobileGL::MG_Backend::DirectGLES {
// dropping it. Returns false when the stream cannot be built, in which case the
// caller must DISABLE the array - leaving a 64-bit array enabled with no pointer is
// what the Adreno driver turns into a SIGSEGV at the next draw.
#if MOBILEGL_PIPE_LEGACY_MEMOS
Bool SyncFloat64AttributeAsFloat32(Uint attribIndex, const MG_State::GLState::VertexAttribute& attrib,
Uint32 fetchBaseInstance);
#endif
#if MOBILEGL_PIPE_PUSH
// The handle arm of the whole vertex-elements half. Everything it needs arrives in
// the applier's records - the bound CSO's two views, the vertex-buffer set, the
// index buffer and the resolved fetch base instance - so it takes no argument at
// all and touches no frontend type. The legacy arm above it is unchanged and both
// compile in every push build (ARCHITECTURE.md 9.6).
void SyncToBackendFromApplier();
// Same narrowing, same memo, same Adreno disable; the source bytes are the shadow
// base the resource call carried and the memo key is the buffer's {slot, gen}.
Bool SyncFloat64AttributeAsFloat32ByHandle(Uint attribIndex, const MGPVertexAttribWire& attrib,
const MG_Pipe::MGPVertexBuffer& binding,
Uint32 fetchBaseInstance);
#endif
// What the converted float32 stream in m_convertedAttributeBufferIds[i] was built
// from. A hit skips the CPU conversion and the re-upload; the buffer's change serial
// is part of the key, so a glBufferSubData into the source invalidates it.
struct ConvertedFloat64Stream {
Bool valid = false;
#if MOBILEGL_PIPE_LEGACY_MEMOS
// The pre-handle pin: a FRONTEND lifetime id, i.e. the key
// ARCHITECTURE.md 9.5 lists for deletion as "ConvertedVertexStreamKey's
// sourcePin". Kept compiled for the legacy arm (and therefore present in
// every pull build, which is what keeps sizeof(this) still).
Uint64 sourceLifetimeId = 0;
#endif
#if MOBILEGL_PIPE_PUSH
// What replaces it: the source buffer's {slot, gen}. It is the SAME identity
// the rest of the backend now keys on, it cannot be reproduced by a recycled
// frontend address, and it costs the walk no allocator probe - the handle is
// already in the vertex-buffer entry that named the source.
MG_Pipe::MGPipeHandle sourceHandle = MG_Pipe::kMGPipeNullHandle;
#endif
// On the handle arm this is the applier's server-owned Serial rather than the
// frontend change serial; both answer the same question - "have the source
// bytes moved since the conversion" - and neither is trusted for a
// persistently mapped buffer, which is written with no call at all.
Uint64 sourceChangeSerial = 0;
SizeT sourceOffset = 0;
SizeT sourceStride = 0;
@@ -772,6 +1122,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
// version early-out in SyncToBackend must not be trusted while it is set.
Bool m_hasConvertedFloat64Attribute = false;
Bool m_isInitialized = false;
#if MOBILEGL_PIPE_LEGACY_MEMOS
// ---- the pre-handle memo set (ARCHITECTURE.md 9.6) -------------------------
// Retired by P3a on the handle arm and kept compiled here so the A/B is real: a
// cleared subsystem bit runs THESE, not a re-keyed twin wearing their names. A
// pull build forces MOBILEGL_PIPE_LEGACY_MEMOS ON, so sizeof(this) does not move
// and no symbol resizes (G1).
Uint16 m_syncedIndexBufferVersion = 0;
// Identity of the buffer the version above was stamped against. Raw and never
// dereferenced: the slot version is a wrapping Uint16 (see the ResolvedDrawBuffers
@@ -787,6 +1143,28 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint32 m_syncedConfigVersion = 0;
Array<MG_State::GLState::VertexAttributeVersion, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS>
m_syncedAttributeVersions;
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
#if MOBILEGL_PIPE_PUSH
// ---- what replaces them on the handle arm (D-G4) ---------------------------
// The bound vertex-elements CSO this twin last emitted, and the applier's
// server-owned content serial for it. Together they replace
// m_hasSyncedConfigVersion + m_syncedConfigVersion AND the whole per-attribute
// version array: the applier's stored Attributes[] IS what was last pushed, so a
// per-attribute compare has nothing left to prove and the walk re-emits.
MG_Pipe::MGPipeHandle m_syncedElementsHandle = MG_Pipe::kMGPipeNullHandle;
Uint64 m_syncedElementsSerial = 0;
Bool m_hasSyncedElements = false;
// The vertex-buffer set's own serial. Not in D-G4's table, and it has to be here:
// set_vertex_buffers is an independent call carrying the buffer identities, the
// offsets and the divisors this twin BAKES into the driver VAO, so a set that
// moved while the format did not must still re-emit them.
Uint64 m_syncedVertexBuffersSerial = 0;
// Replaces m_syncedIndexBufferVersion (a wrapping Uint16) AND
// m_syncedIndexBufferObject (the raw identity patch that closed its wrap hole):
// one monotone Uint64, no wrap, nothing to patch. This is the Track H re-key
// ARCHITECTURE.md 9.5 counts.
Uint64 m_syncedIndexSerial = 0;
#endif
// Byte shift currently baked into the instanced arrays' offsets by the baseInstance
// emulation (see SetPendingFetchBaseInstance). It is draw state, not VAO state, so it
// is deliberately NOT covered by the config version: the frontend never bumps for it.
@@ -800,7 +1178,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint64 m_syncedBufferIdGeneration = 0;
};
extern StateBackendObjectRegistry<MG_State::GLState::VertexArrayObject, BackendVertexArrayObject>
extern TwinRegistry<MG_State::GLState::VertexArrayObject, BackendVertexArrayObject, MG_Pipe::MGPipeKind::VertexElementsCso>
g_backendVertexArrayObjects;
// Shadowed glBindVertexArray: every backend VAO bind goes through here so a
@@ -816,6 +1194,15 @@ namespace MobileGL::MG_Backend::DirectGLES {
// instanced array at element "floor(instance / divisor) + baseInstance", and ES has no
// way to say the "+ baseInstance" part - so it is folded into the attribute's own byte
// offset (baseInstance * stride) for every divisor'd array, which is exactly equivalent.
//
// P3a RETIRES THE AMBIENT GLOBAL (D-H2): an ambient process global cannot cross a
// pushed boundary, so on the handle arm the draw's RAW base instance rides in
// MGPVertexBuffers::BaseInstance and the SERVER decides whether to shift - the answer
// lands in MGPipeApplierState::VertexFetchBaseInstance and the VAO sync reads it there.
// The three declarations below and the three scopes in DirectGLES.cpp are the legacy
// arm's, kept compiled because a cleared subsystem bit has to run a real pre-handle
// path and because removing them would delete two symbols from the PULL build (G1).
#if MOBILEGL_PIPE_LEGACY_MEMOS
// Must be set BEFORE PrepareForDraw so the VAO sync sees it, and cleared after the draw
// so the next one refetches from element 0; ScopedFetchBaseInstance does both.
void SetPendingFetchBaseInstance(Uint32 baseInstance);
@@ -828,6 +1215,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
ScopedFetchBaseInstance(const ScopedFetchBaseInstance&) = delete;
ScopedFetchBaseInstance& operator=(const ScopedFetchBaseInstance&) = delete;
};
#endif
#if MOBILEGL_PIPE_PUSH
// The server-owned half of the same decision, and the reason the client never
// pre-shifts an offset: emulation ownership is the server's (ARCHITECTURE.md 5.7).
// True when the driver applies baseInstance to the vertex fetch itself, in which case
// the attribute-offset emulation must stay out of the way. Applied to whatever the
// applier stored, so the answer is the same whichever side resolved it first.
Bool BackendUsesNativeBaseInstance();
#endif
} // namespace VertexArrayImpl
namespace TextureImpl {
@@ -1121,7 +1518,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
void ActivateTextureUnit(Uint unit);
void UnbindTexture(Uint unit, GLenum target);
extern StateBackendObjectRegistry<MG_State::GLState::ITextureObject, BackendTextureObject>
extern TwinRegistry<MG_State::GLState::ITextureObject, BackendTextureObject, MG_Pipe::MGPipeKind::Texture>
g_backendTextureObjects;
SharedPtr<BackendTextureObject>& SyncTextureObjectToBackend(
const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
@@ -1212,7 +1609,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
Uint64 m_syncedBackendIdGeneration = 0;
};
extern StateBackendObjectRegistry<MG_State::GLState::FramebufferObject, BackendFramebufferObject>
extern TwinRegistry<MG_State::GLState::FramebufferObject, BackendFramebufferObject, MG_Pipe::MGPipeKind::Framebuffer>
g_backendFramebufferObjects;
// True when the read buffer names a fixed-point (norm/snorm) attachment that the
// backend actually stores in a floating-point format. GL clamps a read from a
@@ -1730,7 +2127,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
// skip redundant rebinds. Reset to 0 wherever glUseProgram(0) is issued or the
// ES context is recreated.
extern Uint g_lastUsedBackendProgramId;
extern StateBackendObjectRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl>
extern TwinRegistry<MG_State::GLState::ProgramObject, BackendProgramObjectImpl, MG_Pipe::MGPipeKind::ShaderCso>
g_backendProgramObjects;
// Points one shader storage block of an ALREADY-LINKED backend program at
@@ -1830,7 +2227,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
extern Array<BackendSamplerObject*, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS>
g_boundSamplersCache;
extern StateBackendObjectRegistry<MG_State::GLState::SamplerObject, BackendSamplerObject>
extern TwinRegistry<MG_State::GLState::SamplerObject, BackendSamplerObject, MG_Pipe::MGPipeKind::SamplerCso>
g_backendSamplerObjects;
} // namespace SamplerImpl
@@ -1857,7 +2254,7 @@ namespace MobileGL::MG_Backend::DirectGLES {
Int m_cacheSamples = 0;
};
extern StateBackendObjectRegistry<MG_State::GLState::RenderbufferObject, BackendRenderbufferObject>
extern TwinRegistry<MG_State::GLState::RenderbufferObject, BackendRenderbufferObject, MG_Pipe::MGPipeKind::Renderbuffer>
g_backendRenderbufferObjects;
} // namespace RenderbufferImpl
} // namespace MobileGL::MG_Backend::DirectGLES
+577
View File
@@ -0,0 +1,577 @@
// MobileGL - MobileGL/MG_Backend/DirectGLES/SlotTables.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_Pipe/MGPipeHandles.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#endif
// Espryt 0b, the first Track H slice: the DENSE, {slot, gen}-keyed twin table that replaces
// StateBackendObjectRegistry's UnorderedMap<StateObject*, Entry>.
//
// What changes, and why each of them is the point:
//
// * The KEY stops being a frontend heap address. It is MGPipeHandle{Slot, Gen}, minted by the
// client's MGPipeSlotAllocator off the frontend object's GetLifetimeId(). A recycled heap
// address cannot reproduce a handle, so the weak_ptr the registry carried per entry purely
// to catch that (its Entry::stateRef, used as an IDENTITY test) stops being an identity
// mechanism, and OwnerEquals / TwinLookupMemo x3 / UnitSamplerLookupMemo's owner compare all
// lose their reason to exist.
// * The lookup stops being a hash probe into an open-addressed map and becomes one bounds
// check plus one array index, so a returned BackendPtr* is NOT invalidated by the next Find
// on the table. That kills the hazard Managers.h documents at length, and with it the
// by-value copy plus second Find that SyncTextureObjectToBackend paid to survive it.
// * Slots are dense per kind, which is what lets the server side (ARCHITECTURE.md 10.1,
// MG_Remote/Server/PipeObjectTables) be an array rather than an object graph.
//
// Death is ANNOUNCED, and that is what lets this table have no garbage collector - the
// deliverable ROADMAP.md:18 spells "GC" in and the one D13 makes a precondition of the switch-
// over. All six re-keyed object classes raise MG_State::GLState::NotifyStateObjectDestroyed()
// from their destructor (BufferBackendOps' shape, one entry point for six kinds), the backend
// consumes it in Managers.cpp, and OnFrontendObjectDestroyed() below drops the twin in EVERY
// table of the kind and returns the slot, at the moment the frontend object's last SharedPtr
// goes. So:
// * there is NO draw-path tick, NO creation tick and NO sweep of any kind on this arm. The
// seven CollectGarbageIfNeeded call sites in DirectGLES.cpp drive the LEGACY registry only;
// * a twin, and the driver storage it owns, is freed when the application lets go of the
// object rather than up to 64 creations or 1024 draw ticks later. That is what
// Managers.h's "dead gigabytes" note asked for.
//
// EVERY HOLDER OF THE KIND, not one. Two live tables of one kind is a real configuration - the
// ScopedDirectGLESTextureBindings fixture keeps a by-value copy of the Texture registry for the
// length of a test, and a context reset does the same in reverse - and the slot allocator
// erases its lifetimeId -> slot mapping on Free, so a notice delivered to one holder and
// resolved again by the next would find nothing to resolve. Every table therefore links itself
// into a per-table-type list at construction and out at destruction, and one notice resolves
// the handle ONCE, drops the twin in each holder BY HANDLE, and frees the slot once, last. No
// holder can be left naming a live entry for a dead object, and there is nothing a sweep could
// still find. (The list is per table TYPE; the kind is the type's template parameter, and each
// of the six kinds has exactly one table type in this backend. Magma's subsystem-4 table mints
// out of its own per-renderer allocator, not MGPipeSlots(), so it is not a holder here.)
//
// The weak_ptr per entry survives for exactly one reason: ForEachLive() hands the callee a
// STRONG reference to the frontend object, which the one direct-iteration site
// (ScopedDetachedTextureFramebufferAttachments) needs. It is never an identity test - that is
// what Gen is for - and it is never read to decide whether an entry is dead: a destructor that
// runs after exit() has begun has its notice dropped by InProcessTeardown(), and that twin is
// then a DELIBERATE leak (the process is exiting, the driver reclaims the object, and a twin
// destructor must not call into a driver that may already be unloaded), not something to be
// collected later.
//
// P3+ DEBT, recorded rather than hidden: this header is under MG_Backend/ and it MINTS
// handles (MGPipeSlots().Acquire below) off a frontend SharedPtr's GetLifetimeId().
// MGPipeHandles.h:13-16 says a handle is minted by the CLIENT and never by the server, and
// under a real split neither the frontend object nor its lifetime id exists on this side of
// the wire. This is monolith glue: the minting and the lifetimeId -> handle resolution both
// belong on the client, and the backend should receive the handle in the verb payload. It is
// NOT part of "Track H done" and check_include_closure.py does not probe MG_Backend headers,
// so nothing catches it automatically.
namespace MobileGL::MG_Backend::DirectGLES {
#if MOBILEGL_PIPE_PUSH
// Declared in Managers.h as well; repeated here because this header is included from it
// before that declaration, and the table below is the arming site on this arm (D13: "the
// arming site moves to the slot table's first insertion").
void EnsureProcessTeardownSentinel();
// What the two knobs add up to. Split out as a PURE function of them so a test can drive
// every combination without needing a process per combination.
enum class EsprytSlotArmVerdict {
Handles, // kMGPipeSubsystemEsprytSlots is set: the {slot, gen} tables run.
Legacy, // the bit is clear and the legacy address-keyed registry is reachable.
NoArm, // the bit is clear AND MOBILEGL_PIPE_LEGACY_MEMOS=0 made the legacy arm
// unreachable, so the operator asked for a configuration with no arm at all.
};
EsprytSlotArmVerdict ClassifyEsprytSlotArm(Bool subsystemBitSet, Bool legacyMemosEnabled);
// This process's verdict, read off MG_Config::Features. Latches nothing and stops nothing.
EsprytSlotArmVerdict CurrentEsprytSlotArmVerdict();
// Says, at backend bring-up, that the knobs leave no arm - and does NOT stop.
//
// The stop cannot live here, and that is the whole point of the split. Backend context
// creation runs inside eglMakeCurrent, and the integration harness pre-flights exactly that
// sequence in a FORKED CHILD (MG_IntegrationTest/Harness/HeadlessGL.cpp): a child that dies
// on a signal is reported as "no usable GPU/display/ICD" and every scenario in the lane is
// SKIPPED - i.e. the lane goes green having run nothing, on the very pair of env vars the
// D14/D18 A/B is driven with, which is what ROADMAP.md:7 forbids. So bring-up only
// DIAGNOSES; the stop is raised by ResolveEsprytSlotTablesArm() at the first twin lookup,
// which happens in the test body where the harness reports it as a failure.
//
// The CALL SITE (InitDisplayAndContext in DirectGLES.cpp) is pinned by
// DirectGLESSlotTable.EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs
// the real bring-up entry point under the pair in a forked child: edit that site back to
// ResolveEsprytSlotTablesArm() and the case fails naming both knobs.
void DiagnoseEsprytSlotArm();
// Reads the config, logs, installs the death-notice consumer, and STOPS when the operator
// left no arm at all. Cold: called exactly once per process, from the latch below - i.e. at
// the first twin lookup, which is the first moment an arm is actually needed. A process
// that never twins anything needs no arm and is not stopped.
Bool ResolveEsprytSlotTablesArm();
// True when this process runs the {slot, gen} arm. Fixed for the life of the process: the
// two arms hold their twins in different containers, so flipping mid-run would strand them.
//
// INLINE on purpose. Every Find / GetOrCreate / HandleOf / ForEachLive on the twin tables
// consults it, i.e. it is on the per-draw path several times per draw. As an out-of-line
// function in Managers.cpp (no LTO in any shipped configuration) that was a call through
// the PLT per lookup; here the caller sees a guard-variable load and a perfectly-predicted
// branch, and the arm dispatch folds into the caller.
inline Bool EsprytSlotTablesEnabled() {
static const Bool enabled = ResolveEsprytSlotTablesArm();
return enabled;
}
template <typename StateObject, typename BackendObject, MG_Pipe::MGPipeKind kKind>
class BackendSlotTable {
public:
using StatePtr = SharedPtr<StateObject>;
using StateWeakPtr = std::weak_ptr<StateObject>;
using BackendPtr = SharedPtr<BackendObject>;
// The largest slot index this table will grow to for a handle that ARRIVED in a call's
// payload. Slots are dense and allocated per kind, so a million of one kind is already
// far past any application's live object count; the cap is here because the alternative
// is letting a corrupt 32-bit slot decide a vector resize. See GetOrCreate(MGPipeHandle).
static constexpr Uint32 kMaxHandleSlot = 1u << 20;
struct Entry {
BackendPtr backend;
// LIVENESS ONLY, and only for ForEachLive(), which locks it so the callee holds a
// strong ref. Never compared against another object to decide identity - that is
// what Gen is for - never dereferenced for its address, and never read to decide
// whether the slot is dead: death is announced, not discovered.
StateWeakPtr stateRef;
// The generation this entry's twin was built for. An entry whose Gen no longer
// matches the allocator's is a twin of the slot's PREVIOUS owner.
Uint32 Gen = 0;
Bool Live = false;
};
// Every constructor links the table into the per-type holder list and the destructor
// unlinks it, so a by-value copy (the ScopedDirectGLESTextureBindings fixture's saved
// registry) is a holder for exactly as long as it exists. Copy and move carry the
// ENTRIES and the memo; the links are the table's own and are never copied.
BackendSlotTable() { LinkHolder(); }
BackendSlotTable(const BackendSlotTable& other):
m_slots(other.m_slots),
m_nullTwin(other.m_nullTwin),
m_memoLifetimeId(other.m_memoLifetimeId),
m_memoHandle(other.m_memoHandle) {
LinkHolder();
}
BackendSlotTable(BackendSlotTable&& other) noexcept:
m_slots(std::move(other.m_slots)),
m_nullTwin(std::move(other.m_nullTwin)),
m_memoLifetimeId(other.m_memoLifetimeId),
m_memoHandle(other.m_memoHandle) {
other.m_slots.clear();
other.ForgetHandle();
LinkHolder();
}
BackendSlotTable& operator=(const BackendSlotTable& other) {
if (this != &other) {
m_slots = other.m_slots;
m_nullTwin = other.m_nullTwin;
m_memoLifetimeId = other.m_memoLifetimeId;
m_memoHandle = other.m_memoHandle;
}
return *this;
}
BackendSlotTable& operator=(BackendSlotTable&& other) noexcept {
if (this != &other) {
m_slots = std::move(other.m_slots);
m_nullTwin = std::move(other.m_nullTwin);
m_memoLifetimeId = other.m_memoLifetimeId;
m_memoHandle = other.m_memoHandle;
other.m_slots.clear();
other.ForgetHandle();
}
return *this;
}
~BackendSlotTable() { UnlinkHolder(); }
// Resolve-or-create. The handle comes from the client allocator keyed on the frontend
// object's lifetime id, so two calls for the same live object always land on the same
// slot, and a successor object at the same heap address never does.
BackendPtr& GetOrCreate(const StatePtr& stateObj) {
// No assert on null here, unlike the map arm: null is TOLERATED, so a DEBUG build
// must not trap where the release build quietly does the documented thing.
if (stateObj == nullptr) {
// The registry this replaces inserted a null KEY and handed back that entry's
// twin (DirectGLES.cpp's SyncTextureObjectToBackend documents relying on
// exactly that tolerance), so a release build never dereferenced null here.
// Keep the shape exactly, INCLUDING across calls: the map kept its null-keyed
// entry, so a second null call was handed the same twin the first one got.
// Resetting here instead would have destroyed it - an arm difference in the one
// path that documents relying on this. One per-table parking slot, never live,
// never handed a handle, because a null object has no identity and
// therefore cannot have a {slot, gen}.
return m_nullTwin;
}
// D13: the teardown sentinel is armed by the slot table's first insertion. Twin
// creation is the moment a driver-owned id starts needing a guarded destructor;
// this is the cold path, so the once-guard costs nothing per draw. On the legacy
// arm StateBackendObjectRegistry::GetOrCreate arms it itself.
EnsureProcessTeardownSentinel();
const MG_Pipe::MGPipeHandle handle =
MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId());
MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle),
"MGPipe slot space of kind %u is exhausted",
static_cast<Uint32>(kKind));
Entry& entry = EntryAt(handle.Slot);
if (entry.Live && entry.Gen != handle.Gen) {
// The slot was reclaimed and handed to a new object: the twin at it describes
// driver ids the new state object never made.
entry.backend.reset();
}
entry.Gen = handle.Gen;
entry.Live = true;
entry.stateRef = stateObj;
// No creation tick and no sweep here. The registry this replaces needed both,
// because nothing told it a texture or a renderbuffer had been DELETED and object
// CHURN rather than draw count is what made that urgent. Every one of the six kinds
// now announces its own death from its destructor, so a dead twin's slot is already
// back before the next creation asks for one.
RememberHandle(stateObj->GetLifetimeId(), handle);
return entry.backend;
}
// P3a: resolve-or-create BY HANDLE, and it is the shape that discharges the debt this
// header records against itself at the top of the file.
//
// The overload above mints - it calls MGPipeSlots().Acquire off a frontend object's
// lifetime id, from inside MG_Backend - which is monolith glue: a handle is minted by
// the CLIENT, and under a real split neither the object nor its lifetime id exists on
// this side. This overload never touches the allocator at all. The handle ARRIVED, in
// the call's payload, already minted by the side that owns minting; all this does is
// index the slot, notice a generation that no longer matches (the slot was recycled,
// so the twin at it describes driver ids the new resource never made) and hand back
// the twin pointer. FindByHandle beside it is the same shape and already existed.
//
// No StatePtr, therefore no Entry::stateRef: the weak pointer is liveness for
// ForEachLive() and a handle-keyed entry has no frontend object to weakly hold. Such
// an entry is therefore invisible to ForEachLive, which is correct - the one direct
// iteration site walks texture twins, and it is not one of these tables.
//
// Death stays ANNOUNCED, as it is on the other overload: for a handle-keyed kind the
// announcement is the family's own destroy call, not the shared death notice, and the
// slot is freed by the CLIENT after that call returns.
//
// UNUSED AT THE CONTRACT COMMIT, deliberately: it is a member of a class template, so
// an uninstantiated one costs nothing anywhere, and the backend package is what gives
// it its first caller.
BackendPtr& GetOrCreate(MG_Pipe::MGPipeHandle handle) {
MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle),
"GetOrCreate(handle) named the reserved null handle");
if (MG_Pipe::MGPipeHandleIsNull(handle)) return m_nullTwin;
// A slot index that ARRIVED in a payload indexes a vector this call would RESIZE,
// and nothing between the payload and here bounds it: the applier's blob gates sit
// in front of the vertex-input family, not in front of the resource family, which
// dispatches ops->Create(record.Res, ...) straight through. There is no allocator
// constant to check against on this side - the allocator is the client's - so this
// is a sanity cap and is documented as one: kMaxHandleSlot entries of one kind is
// already orders of magnitude past any real GL object count, while a corrupt 32-bit
// slot asks for a four-billion-entry resize.
if (handle.Slot >= kMaxHandleSlot) {
MOBILEGL_ASSERT(false, "GetOrCreate(handle) named slot %u, past this table's %u bound",
handle.Slot, kMaxHandleSlot);
return m_nullTwin;
}
// Same arming as the minting overload, and for the same reason: twin creation is
// the moment a driver-owned id starts needing a guarded destructor.
EnsureProcessTeardownSentinel();
// THE TWO DIRECTIONS ARE NOT SYMMETRIC HERE, where they are on the minting overload.
// There the handle comes straight out of MGPipeSlots().Acquire and can never be
// BEHIND the entry, so a bare `!=` only ever means "the slot was recycled forward".
// Here the handle arrived in a payload, so `handle.Gen < entry.Gen` is a reachable
// input, and adopting it would destroy the INCUMBENT LIVE twin - a driver buffer id,
// a persistent map, a pooled store, released by a defaulted destructor that issues
// no glDeleteBuffers and no pool enrolment - and then stamp the slot back to the
// dead resource's generation, after which the incumbent's own FindByHandle refuses
// it and it is silently handed a fresh, empty twin. That is a leak AND a resource
// that loses its storage with no diagnostic, i.e. the shape commit d7655247 fixed
// and the thing MGPipeHandle::Gen exists to prevent. So: forward is a recycle and
// resets the twin, BACKWARD is refused - which is the same answer FindByHandle
// below already gives the same input.
Entry& entry = EntryAt(handle.Slot);
if (entry.Live && entry.Gen > handle.Gen) {
MOBILEGL_ASSERT(false,
"GetOrCreate(handle) named generation %u at slot %u, which is BEHIND "
"the live entry's %u - refusing rather than destroying the incumbent",
handle.Gen, handle.Slot, entry.Gen);
return m_nullTwin;
}
if (entry.Live && entry.Gen != handle.Gen) entry.backend.reset();
entry.Gen = handle.Gen;
entry.Live = true;
return entry.backend;
}
// The generation of the LIVE entry at this slot, or 0 when the slot is out of range or
// holds no live entry. It exists so a caller can DIAGNOSE - in a release build, where
// MOBILEGL_ASSERT is inert - the refusal GetOrCreate(handle) above performs silently.
Uint32 LiveGenAt(Uint32 slot) const {
if (slot >= m_slots.size()) return 0;
const Entry& entry = m_slots[slot];
return entry.Live ? entry.Gen : 0;
}
// P3a: the death half of the overload above, for a kind whose announcement is its own
// destroy CALL rather than the shared death notice (D-L). Hands the twin OUT rather
// than destroying it in place, because the caller may still have to decide what
// happens to the driver id it owns - Espryt pools it, deletes it, or parks it on the
// deferred-release list when no context is current on this thread - and every one of
// those outcomes has to be reached with the entry already retired, so a re-entrant
// GetOrCreate from a twin destructor cannot resurrect it.
//
// The slot itself is NOT freed here: it belongs to the kind, and for a handle-keyed
// kind the CLIENT frees it after the destroy call returns (SlotAllocator.h:60 - the
// Gen bump rides the next handout, so a double free cannot skip a generation). An
// entry whose Gen no longer matches is a twin of the slot's previous owner and is
// left alone: the successor's own GetOrCreate resets it.
BackendPtr ReleaseByHandle(MG_Pipe::MGPipeHandle handle) {
if (MG_Pipe::MGPipeHandleIsNull(handle)) return BackendPtr{};
if (m_memoHandle.Slot == handle.Slot) ForgetHandle();
if (handle.Slot >= m_slots.size()) return BackendPtr{};
Entry& entry = m_slots[handle.Slot];
if (!entry.Live || entry.Gen != handle.Gen) return BackendPtr{};
BackendPtr dead = std::move(entry.backend);
entry.backend.reset();
entry.stateRef.reset();
entry.Live = false;
return dead;
}
// Null when no live twin of this object exists. Unlike the registry's Find this NEVER
// mutates the table, so the returned pointer survives any later Find on it; only a
// GetOrCreate that grows the vector can move it, and callers that hold one across a
// possible insertion still copy the BackendPtr out.
BackendPtr* Find(StateObject* stateObj) {
if (stateObj == nullptr) return nullptr;
return FindByHandle(HandleOf(stateObj));
}
const BackendPtr* Find(StateObject* stateObj) const {
return const_cast<BackendSlotTable*>(this)->Find(stateObj);
}
BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) {
if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr;
if (handle.Slot >= m_slots.size()) return nullptr;
Entry& entry = m_slots[handle.Slot];
if (!entry.Live || entry.Gen != handle.Gen) return nullptr;
return &entry.backend;
}
// The handle this object's twin is keyed on, or the null handle. This is what a backend
// memo stores instead of a raw pointer, a GL name or a bare lifetime id.
//
// A NULL answer is never memoised. The memo is per table and the allocator is per
// kind, so with two holders of one kind the OTHER table can be the one that acquires;
// a cached "no handle" here would then outlive the twin's creation over there, and
// nothing on this table's own acquire path would ever refresh it. A miss costs the
// allocator probe it always cost; a hit is refreshed the moment anyone acquires.
MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const {
if (stateObj == nullptr) return MG_Pipe::kMGPipeNullHandle;
const Uint64 lifetimeId = stateObj->GetLifetimeId();
if (lifetimeId == m_memoLifetimeId) return m_memoHandle;
const MG_Pipe::MGPipeHandle handle =
MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId);
if (!MG_Pipe::MGPipeHandleIsNull(handle)) RememberHandle(lifetimeId, handle);
return handle;
}
// P2 step e2's backend half. The frontend object with this lifetime id has just been
// DESTROYED: resolve its handle ONCE, drop its twin in EVERY table of this type, and
// return the slot to the allocator - in that order, because the allocator forgets the
// lifetime id on Free and a holder told second could no longer resolve it.
//
// The slot is returned whether or not any holder still had a twin at it: the lifetime
// id is dead and MG_State never hands one out twice, so nothing can acquire it again,
// and a slot minted for it that no table holds (a table reset with `= {}` drops its
// entries without freeing) would otherwise stay allocated for the life of the process.
//
// STATIC, and deliberately so: a notice is about an object, not about a table, and
// "which table holds it" is exactly the question that produced the two-holder leak.
// Returns whether the object had a slot of this kind, i.e. whether anything was freed;
// a second call for the same id answers false because the allocator no longer maps it.
static Bool OnFrontendObjectDestroyed(Uint64 lifetimeId) {
const MG_Pipe::MGPipeHandle handle =
MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId);
if (MG_Pipe::MGPipeHandleIsNull(handle)) return false;
for (BackendSlotTable* holder = s_firstHolder; holder != nullptr;) {
// The successor is read BEFORE the release: ReleaseTwinAt runs the twin's
// destructor, which is a driver call, and nothing that outlives it may be a
// reference into this holder.
BackendSlotTable* const next = holder->m_nextHolder;
holder->ReleaseTwinAt(handle);
holder = next;
}
MG_Pipe::MGPipeSlots().Free(kKind, handle);
return true;
}
// How many tables of this type exist right now. For the tests that pin the holder
// list; nothing on a shipping path asks.
static Uint32 HolderCount() {
Uint32 count = 0;
for (const BackendSlotTable* holder = s_firstHolder; holder != nullptr;
holder = holder->m_nextHolder) {
++count;
}
return count;
}
// fn(const StatePtr& state, const BackendPtr& twin) over every live, still-owned entry.
// Replaces the registry's begin()/end(), whose iterator exposed the raw frontend
// address as the map key - the one place the backend read an identity it must not have.
// The state object is handed over as a STRONG reference, so the callee cannot be handed
// a dangling key the way the old iteration could.
template <typename Fn>
void ForEachLive(Fn&& fn) const {
// Index loop and a COPIED twin, not a range-for over references: fn is arbitrary
// backend code, and a nested GetOrCreate on this table would resize m_slots and
// invalidate both the iterator and any reference into the vector that outlives the
// call. The one caller today happens not to insert; that is not a property the
// walk should depend on.
for (SizeT slot = 0; slot < m_slots.size(); ++slot) {
const Entry& entry = m_slots[slot];
if (!entry.Live || !entry.backend) continue;
const StatePtr state = entry.stateRef.lock();
if (!state) continue;
const BackendPtr twin = entry.backend;
fn(state, twin);
}
}
Uint32 LiveCount() const {
Uint32 count = 0;
for (const Entry& entry : m_slots) {
if (entry.Live) ++count;
}
return count;
}
private:
// Drop the twin at `handle` if THIS table holds it. Frees nothing: the slot belongs to
// the kind, not to the table, and OnFrontendObjectDestroyed returns it once, after
// every holder has let go.
Bool ReleaseTwinAt(MG_Pipe::MGPipeHandle handle) {
// Forget the memo whenever it names this slot, even if this table has no entry
// there: a memo can be a handle learned from the allocator for an object another
// holder twinned, and it must not survive the slot's next handout.
if (m_memoHandle.Slot == handle.Slot) ForgetHandle();
if (handle.Slot >= m_slots.size()) return false;
// The twin's destructor is a driver call and could, in principle, re-enter
// GetOrCreate on this table and resize m_slots. So NOTHING that outlives the
// destructor may be a reference into m_slots: the twin is moved out into a local,
// the entry is finished with, and only then is the local released.
BackendPtr dead;
{
Entry& entry = m_slots[handle.Slot];
if (!entry.Live || entry.Gen != handle.Gen) return false;
dead = std::move(entry.backend);
entry.backend.reset();
entry.stateRef.reset();
entry.Live = false;
}
dead.reset();
return true;
}
// Grows the table to hold `slot`. Every caller bounds `slot` first - the minting
// overload because the allocator produced it, the handle overload against
// kMaxHandleSlot - because this is the one place a client-supplied number decides an
// allocation size.
Entry& EntryAt(Uint32 slot) {
if (slot >= m_slots.size()) m_slots.resize(static_cast<SizeT>(slot) + 1);
return m_slots[slot];
}
void RememberHandle(Uint64 lifetimeId, MG_Pipe::MGPipeHandle handle) const {
m_memoLifetimeId = lifetimeId;
m_memoHandle = handle;
}
void ForgetHandle() const {
m_memoLifetimeId = 0;
m_memoHandle = MG_Pipe::kMGPipeNullHandle;
}
// The holder list: intrusive and doubly linked, so registering and unregistering are
// two pointer writes with no allocation, and its head is a constant-initialised
// static - which is what lets the process-lifetime registry globals in Managers.cpp
// link themselves in from their own constructors with no initialisation-order
// question to answer. Single-threaded, like every table it links (the tables live and
// die on the context thread, as the notice they answer does).
void LinkHolder() {
m_prevHolder = nullptr;
m_nextHolder = s_firstHolder;
if (s_firstHolder != nullptr) s_firstHolder->m_prevHolder = this;
s_firstHolder = this;
}
void UnlinkHolder() {
if (m_prevHolder != nullptr) {
m_prevHolder->m_nextHolder = m_nextHolder;
} else {
s_firstHolder = m_nextHolder;
}
if (m_nextHolder != nullptr) m_nextHolder->m_prevHolder = m_prevHolder;
m_prevHolder = nullptr;
m_nextHolder = nullptr;
}
static inline BackendSlotTable* s_firstHolder = nullptr;
BackendSlotTable* m_prevHolder = nullptr;
BackendSlotTable* m_nextHolder = nullptr;
// Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live.
Vector<Entry> m_slots;
// Handed back by GetOrCreate for a null state object. Never live, never handed a handle.
BackendPtr m_nullTwin;
// ONE-entry resolution memo, lifetimeId -> handle. It exists because without it every
// resolution goes through the allocator's ByLifetimeId hash, which the deleted
// TwinLookupMemos existed to avoid and which D13 promises to replace with "direct slot
// indexing".
//
// It is one entry and therefore only helps a caller that asks for the SAME object twice
// running - ResolveVaoTwin and SyncCurrentProgram do, once per draw each. Two callers
// it does NOT help, recorded rather than claimed away: BindCurrentFBO resolves BOTH
// targets in a frame, and ResolveUnitSamplerBackend asks for a different sampler per
// texture unit, so both thrash a single-entry memo and pay the probe P1 did not (P1 had
// a per-unit memo and a direct-mapped 6-slot array there). Making the memo per-unit /
// per-target is the fix, and G11 - the device-side gate that would price it - is owed.
//
// It cannot serve a stale answer, by three independent arguments:
// * the key is a lifetime id, which MG_State never hands out twice, so a recycled
// heap address cannot hit this memo the way it could hit an address-keyed one;
// * a null answer is never stored, so another holder's acquire cannot be hidden by
// a "no handle" this table remembered earlier; and
// * even a hit for a slot that has since been freed and re-handed is caught, because
// the caller resolves the handle through FindByHandle, which compares Gen.
// Cleared anyway when a death notice names the memoised slot. 0 is never a live
// lifetime id (MG_State's counters start at 1), so a zeroed memo is a guaranteed miss.
mutable Uint64 m_memoLifetimeId = 0;
mutable MG_Pipe::MGPipeHandle m_memoHandle = MG_Pipe::kMGPipeNullHandle;
};
#endif // MOBILEGL_PIPE_PUSH
} // namespace MobileGL::MG_Backend::DirectGLES
@@ -0,0 +1,498 @@
// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <Config.h>
#if MOBILEGL_PIPE_PUSH
// kMGPipeSubsystem* - the runtime bitmask's named bits - and MGPipeHandle itself. Both are
// header-only constant/POD declarations, and both are push-only, so the pull build's include
// graph is unchanged (G1).
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeHandles.h>
#endif
#include <cstdlib>
// Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14), and the
// {slot, gen} mint the re-keyed sites are written against.
//
// Two switches decide which arm a re-keyed site runs, and they are NOT the same switch:
//
// MOBILEGL_PIPE_PUSH (compile) - is the pushed state there to be keyed on at all
// Features.PipePush (runtime bitmask) - is THIS subsystem migrated in THIS run
// MOBILEGL_PIPE_LEGACY_MEMOS (compile) - is the pre-handle arm compiled beside it
// Features.PipeLegacyMemos (runtime) - may the pre-handle arm be ENTERED in this run
//
// ARCHITECTURE.md 9.6's point: once a handle wave lands, a clear MOBILEGL_PIPE_PUSH bit is
// only a valid A/B while the legacy arm is still compiled, because with the bit clear the
// backend would otherwise still run the re-keyed code. So a clear bit selects the legacy
// arm, and a run that has explicitly disabled the legacy arm may not fall into it.
//
// D14 spends that last sentence at STARTUP, not per draw: "a Track-H subsystem whose bit is
// clear is a startup Fatal{PipeLegacyMemosDisabled}". Nothing in the draw path aborts, and
// nothing outside Track H consults the legacy-memo lever at all - see
// MagmaPipeValidateSubsystemConfiguration below for both halves of that rule.
//
// The whole header is inert in a pull build: MOBILEGL_PIPE_PUSH is 0 there, every helper
// below is behind it, and the pull build's translation units are byte-identical (G1).
namespace MobileGL::MG_Backend::DirectVulkan {
#if MOBILEGL_PIPE_PUSH
// Is `subsystemBit` (MG_Pipe/MGPipe.h's kMGPipeSubsystem*) migrated in this run?
inline Bool MagmaPipeSubsystemOn(Uint64 subsystemBit) {
return (MG_Config::Features.PipePush & subsystemBit) != 0;
}
// ---------------------------------------------------------------------------------
// D14's startup gate
// ---------------------------------------------------------------------------------
//
// Called once from VulkanRenderer::Initialize(), i.e. only when Magma is the backend
// that is actually running. It answers exactly one question and it answers it before the
// first draw: is there an arm for Magma's Track-H subsystem in this configuration?
//
// Three deliberate boundaries, each of which the per-draw shape this replaces got wrong:
//
// * ONLY Magma's own Track-H bit is checked. Espryt's bit 5 is Espryt's business (a
// DirectVulkan run does not execute one line of DirectGLES' re-key), so
// MOBILEGL_PIPE_PUSH=0x20 must not kill a Magma run, and MOBILEGL_PIPE_PUSH=0x40 must
// not kill an Espryt one.
// * bit 0 (kMGPipeSubsystemRenderState) is NOT Track H and is NOT fatal. It is not a
// memo re-key at all: it decides where the pipeline memo's STATE KEY comes from, and
// a clear bit there simply means the client is not pushing render-state CSOs in this
// run, which GetOrCreatePipeline answers with its own state hash. D14 labels bits 5
// and 6 "Track H" and labels bit 0 nothing of the sort.
// * it is Fatal at STARTUP, once, not on a draw. A per-draw abort inside
// GetOrCreatePipeline turns a configuration mistake into a mid-frame crash and puts a
// branch nobody needs on the hottest path in the backend.
//
// [declared deviation from D14, review v2 minor 2] D14's runtime row reads "false: the
// legacy arm is never entered", and D14's compile-switch row names ComputePipelineStateHash
// as part of the pre-handle arm. Those two together would make MOBILEGL_PIPE_LEGACY_MEMOS=0
// with bit 0 CLEAR a contradiction: the pipeline memo has no CSO handle to key on, so it
// keys on a state hash, and in a build that compiles the pre-handle arm that hash IS
// ComputePipelineStateHash. Magma does not make that fatal - bit 0 is not Track H, and
// there is a correct answer (the state hash) where for bits 5/6 there is none - but it no
// longer does it SILENTLY: the combination is named once, at startup, right here.
inline void MagmaPipeValidateSubsystemConfiguration() {
if (!MG_Config::Features.PipeLegacyMemos &&
!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) {
MGLOG_W("MGPipe: MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState (bit 0 "
"of MOBILEGL_PIPE_PUSH) clear - Magma's pipeline memo has no CSO handle to key "
"on, so every draw whose pipeline-state version moved runs the pre-handle STATE "
"HASH instead. That is not a Track-H subsystem and not fatal, but it is not the "
"handle arm either: set bit 0 (MOBILEGL_PIPE_PUSH=0x%llx) if this run was meant "
"to measure it.",
static_cast<unsigned long long>(MG_Config::Features.PipePush |
MG_Pipe::kMGPipeSubsystemRenderState));
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
// The pre-handle arm is compiled AND the operator has not forbidden entering it, so a
// clear bit is an ordinary, valid A/B: the site takes the legacy arm.
if (MG_Config::Features.PipeLegacyMemos) return;
#endif
if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) return;
#if MOBILEGL_PIPE_LEGACY_MEMOS
const char* const why = "this run has MOBILEGL_PIPE_LEGACY_MEMOS=0";
#else
const char* const why =
"this build has cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF, which compiles no such arm";
#endif
MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} Magma's Track-H subsystem "
"(kMGPipeSubsystemMagmaVertexInput, bit 6 of MOBILEGL_PIPE_PUSH) is clear, so the "
"vertex-input cache and the VAO draw memo want the pre-handle arm - but %s. Set "
"bit 6 (MOBILEGL_PIPE_PUSH=0x%llx, or the default 0x%llx), or allow the legacy arm.",
why,
static_cast<unsigned long long>(MG_Config::Features.PipePush |
MG_Pipe::kMGPipeSubsystemMagmaVertexInput),
static_cast<unsigned long long>(MG_Pipe::kMGPipeSubsystemsMigratedAtP2));
std::abort();
}
// "Does this Track-H site run the handle arm?" - the ONE question every re-keyed Track-H
// site asks, so that they cannot disagree with each other or with the startup gate.
inline Bool MagmaPipeTrackHArmIsHandles(Uint64 trackHBit) {
#if MOBILEGL_PIPE_LEGACY_MEMOS
return MagmaPipeSubsystemOn(trackHBit);
#else
// No pre-handle arm exists in this build, and MagmaPipeValidateSubsystemConfiguration
// has already made a clear bit a startup Fatal, so the handle arm is the only arm a
// running process can be on.
(void)trackHBit;
return true;
#endif
}
// ---------------------------------------------------------------------------------
// Negative control C (P2 brief D18): MOBILEGL_PIPE_HANDLE_ABA_CONTROL
// ---------------------------------------------------------------------------------
//
// "Is the object-identity half of every vertex-input memo key deliberately defeated in
// this run?" - the ONE question the control's sites ask, for the same reason
// MagmaPipeTrackHArmIsHandles exists: three sites deciding separately could disagree,
// and a control that defeats two of three guards proves nothing.
//
// WHAT IT DEFEATS, AND WHY IT IS SPELLED AS "REPLACE THE IDENTITY WITH A CONSTANT"
// RATHER THAN "USE THE HEAP ADDRESS".
//
// D18 wrote the control as "hash attr.Buffer.get() instead of GetLifetimeId(), and skip
// the vaoLifetimeId compare", on the theory that a deleted object's replacement lands at
// the freed heap block and so reproduces the key. Measured, it does not: in
// HandleRecycleScenario the GL NAMES come back (glGen* hands the deleted name straight
// out) but the C++ heap blocks do not - a VertexArrayObject is 3920 bytes, too large for
// glibc's tcache, so its chunk goes to the unsorted bin and is split by the very next
// allocation the replacement path makes. Four create/delete cycles in one run produced
// four distinct addresses, ~1 MiB apart. With no address reuse there is nothing for
// "hash the address" to collide with: the replacement hashes differently, indexes a
// different memo slot, and inherits nothing - so the arm asserted stale pixels and saw
// fresh ones, which is a FAILING negative control that had stopped controlling anything.
//
// So the control no longer asks the allocator for the collision; it manufactures it. On
// both arms the object identity is replaced by a constant, which is the strongest form of
// "the allocator handed the block back" and is deterministic. That covers strictly more
// than D18's spelling, and in particular it reaches the arm P2 SHIPS: on the handle arm
// the constant defeats the OBJECT IDENTITY THAT SELECTS THE SLOT - the key the handle arm
// ships - so the replacement VAO is handed the dead one's memo entry and its content hash.
// Defeating only the retired lifetime-id/address guards would leave that key untested,
// which is exactly the vacuity this control exists to catch.
//
// WHAT IT DOES NOT COVER, AND WHY NO REPRODUCER OF THIS SHAPE CAN [fix-aba review v1,
// MAJOR 1]. It does NOT exercise the GENERATION half of {slot, gen}:
//
// * this mint has no death notification - nothing in MG_Backend/DirectVulkan consumes
// NotifyStateObjectDestroyed - so a slot returns to the free list only through
// OnFrameBoundary's age sweep (kSweepInterval 256, kRetireAgeBoundaries 1024, below);
// * HandleRecycleScenario issues five frame boundaries, so the free list is empty when
// the replacement VAO acquires and it 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 the ++Gen below leaves all four arms green;
// * 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 the only memo
// that carries a GPU slice rather than a layout, ResolvedVertexBindings, declines
// across frames by design. The two requirements are mutually exclusive, so the
// generation is out of reach of any same-frame pixel reproducer for this memo.
//
// The generation is covered where it IS expressible, over this mint and the claim rule
// MagmaPipeClaimSlotMemos below: MG_Test/Pipe/MagmaPipeIdentityTest.cpp drives a real
// retire -> reuse and asserts that a memo stamped at {slot, gen=N} is not served at
// {slot, gen=N+1} with the knob off and IS served with it on. Deleting the ++Gen reds that
// suite; it is the only place in the tree where that deletion is caught.
//
// Everything the control does NOT defeat is as load-bearing as what it does. It never
// touches a guard that is not an IDENTITY guard: the resolved-bindings memo's frame
// serial, its slice-epoch compares and its host-map check all stay in force, so a green
// AbaControl arm still means "a replacement object was handed its dead predecessor's
// resolved vertex bindings because the identity halves of the keys were defeated", not
// "every safety net was switched off until something broke".
//
// Off by default (Config.h), set only by the HandleRecycle AbaControl ctest lanes, and
// #if MOBILEGL_PIPE_PUSH throughout, so no shipping pull build can even parse it.
inline Bool MagmaPipeAbaControlDefeatsIdentity() {
return MG_Config::Features.PipeHandleAbaControl;
}
// The single consumer-table entry every VAO collapses onto while the control is on. Slot
// 0 is a real, ordinary entry of both tables (MagmaPipeSlotIndex maps the first allocatable
// handle onto it), so nothing about the tables changes shape for the control's sake.
inline constexpr Uint32 kMagmaPipeAbaControlSlotIndex = 0;
// ---------------------------------------------------------------------------------
// The {slot, gen} mint
// ---------------------------------------------------------------------------------
//
// Maps a frontend object's never-reused lifetime id to a dense {slot, gen}. Three
// properties, and the third is the one review v2 got wrong:
//
// 1. exact identity - Gen moves whenever a slot changes owner, so a stale handle can
// never match a live object even if the allocator hands back the same heap address
// (the ABA HandleRecycleScenario reproduces);
// 2. dense slots - the slot IS an index, so a consumer's per-slot table needs no hash,
// no probe and no mix;
// 3. NO CAPACITY CLIFF. A live object's handle never changes while the object is being
// drawn, whatever the working set size.
//
// Property 3 is why this is not the fixed 2-way set-associative LRU the previous round
// shipped. That structure evicted a LIVE object once the working set passed its capacity,
// and every consumer memo keyed on the handle died with it: measured on a verbatim
// transcription, 54% of uses lost their handle 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, which is the Minecraft chunk shape this exists for). Two of the three memos it
// fed - the content-hash memo and the resolved-state memo - had NO capacity before this
// package: they were unbounded mutable fields on VertexArrayObject. Introducing eviction
// there turns one ComputeHash per VAO reconfiguration into one per DRAW, and, once the
// buffer table thrashes too, makes the vertex-input content hash a per-draw value that
// inserts a fresh heap-allocated BackendVertexInputState into an unbounded map on every
// draw. That is a worse leak than the one it was introduced to avoid.
//
// So: grow on demand, and reclaim by AGE instead of by capacity.
//
// * Acquire hits an UnorderedMap<lifetimeId, slotIndex>, in front of which sits a
// one-entry memo. Every re-keyed site in a draw asks about the SAME VAO, so the memo
// turns the five-or-six acquisitions a draw makes into one map probe plus five Uint64
// compares - less than the address multiply plus two-way probe the pre-handle arm ran.
// * OnFrameBoundary retires slots whose object has not been drawn for
// kRetireAgeBoundaries boundaries and returns them to a free list, so the table's
// footprint tracks the LIVE DRAWN working set, not objects ever created. That is the
// property MG_Impl/Pipe/SlotAllocator cannot have here: nothing in P2 can call its
// Free (the tracker emits no object-class state, BufferBackendOps::OnDestroy is handed
// a BackendBufferResource rather than the BufferObject, and VertexArrayObject has no
// death hook at all - adding one is D13's explicit-destroy work, which covers Espryt's
// six kinds, not VertexElementsCso), so an allocator here would grow by one SlotState
// plus one map node per object EVER created, for the life of the process, on a
// platform with an LMK. Age-based reclamation is the stand-in for the death
// notification, and it is exactly as ABA-proof, because reuse bumps Gen.
// * A retire costs at most one memo recompute if the object is drawn again - the same
// price a cache miss costs - and it is charged only to objects that went idle for
// ~1024 frames, never to a hot one.
//
// Memory: one map node plus one 24-byte Entry per live object, i.e. tens of bytes against
// the kilobyte a VertexArrayObject or a BufferObject already costs the frontend. There is
// no capacity to size off a device measurement because there is no capacity; what the
// device run in D.4.2 can still want is the number itself, so the high-water mark is
// logged at MGLOG_D on the allocate-a-new-slot branch (once per new object, never on a
// draw - ROADMAP.md:7).
//
// Single-threaded, like the rest of the renderer. Owned per VulkanRenderer (see
// MagmaPipeIdentityTables): a process-global would share one table, and one reclamation
// clock, across two live contexts.
class MagmaPipeIdentityTable {
public:
explicit MagmaPipeIdentityTable(const char* kindName) : m_kindName(kindName) {}
// Slots ever minted. A consumer table indexed by MagmaPipeSlotIndex() needs this many
// entries; MagmaPipeSlotTable below grows itself, so nobody has to ask.
Uint32 Count() const { return static_cast<Uint32>(m_entries.size()); }
// Objects currently holding a slot - the live working set this table tracks.
Uint32 LiveCount() const { return static_cast<Uint32>(m_index.size()); }
MG_Pipe::MGPipeHandle Acquire(Uint64 lifetimeId) {
// Unreachable: MG_State hands out lifetime ids from 1 precisely so that a
// zero-initialised memo slot cannot carry a live object's id. Guarded anyway so
// that a zero can never be minted into a slot and then indexed with.
if (lifetimeId == 0) return MG_Pipe::kMGPipeNullHandle;
// The one-entry front memo. Cleared by any retire, so it can never serve a slot
// that has been handed back to the free list.
if (lifetimeId == m_lastLifetimeId) {
m_entries[m_lastIndex].LastUse = m_boundary;
return m_lastHandle;
}
Uint32 index = 0;
const auto it = m_index.find(lifetimeId);
if (it != m_index.end()) {
index = it->second;
} else {
index = ClaimSlot();
m_entries[index].LifetimeId = lifetimeId;
m_index.emplace(lifetimeId, index);
}
Entry& entry = m_entries[index];
entry.LastUse = m_boundary;
m_lastLifetimeId = lifetimeId;
m_lastIndex = index;
m_lastHandle = MG_Pipe::MGPipeHandle{index + MG_Pipe::kMGPipeFirstAllocatableSlot,
entry.Gen};
return m_lastHandle;
}
// Ages the table and returns idle slots to the free list. Same shape and the same
// self-gating as VertexInputStateFactory::OnFrameBoundary, which is what the reclaimed
// slots' consumers use.
void OnFrameBoundary() {
++m_boundary;
if ((m_boundary % kSweepInterval) != 0) return;
SizeT retired = 0;
for (auto it = m_index.begin(); it != m_index.end();) {
Entry& entry = m_entries[it->second];
if ((m_boundary - entry.LastUse) > kRetireAgeBoundaries) {
entry.LifetimeId = 0;
m_freeSlots.push_back(it->second);
it = m_index.erase(it);
++retired;
} else {
++it;
}
}
if (retired != 0) {
// A retired slot's Gen has not moved yet - it moves when the slot is reused -
// so a front memo pointing at one would still hand out a handle the consumer
// tables would accept. Drop it.
m_lastLifetimeId = 0;
m_lastHandle = MG_Pipe::kMGPipeNullHandle;
MGLOG_D("MagmaPipeIdentityTable(%s): retired %zu idle slots, %u live of %u minted",
m_kindName, retired, LiveCount(), Count());
}
}
private:
// Sweep cadence and retirement age, deliberately the same numbers
// VertexInputStateFactory::OnFrameBoundary uses for the entries these slots key: a slot
// retired earlier than its cache entry would mint a new handle for an object whose
// entry is still live and still correct, which is a pure waste.
static constexpr Uint64 kSweepInterval = 256;
static constexpr Uint64 kRetireAgeBoundaries = 1024;
struct Entry {
Uint64 LifetimeId = 0;
Uint64 LastUse = 0;
// Moves ONLY on slot reuse, never on respecify: an object that keeps its slot keeps
// its generation, which is what makes a memo survive a reconfiguration.
Uint32 Gen = 0;
};
Uint32 ClaimSlot() {
while (!m_freeSlots.empty()) {
const Uint32 index = m_freeSlots.back();
m_freeSlots.pop_back();
// MGPipeHandles.h:52-58 defends the Gen wrap only in a debug allocator, and
// MOBILEGL_ASSERT is compiled out of every build P2 runs (Defines.h: asserts are
// live only at MOBILEGL_LOG_ACTIVE_LEVEL == DEBUG). So the wrap is handled on the
// RELEASE path instead of asserted: a slot that has been reused 2^32 times is
// permanently retired rather than wrapped, because a wrapped Gen would let a
// stale handle match a live object. It costs one slot.
if (m_entries[index].Gen == ~Uint32{0}) {
MGLOG_W("MagmaPipeIdentityTable(%s): slot %u reached generation 2^32-1 and is "
"retired for good; {slot, gen} stays unique",
m_kindName, index + MG_Pipe::kMGPipeFirstAllocatableSlot);
continue;
}
++m_entries[index].Gen;
return index;
}
const Uint32 index = static_cast<Uint32>(m_entries.size());
m_entries.push_back(Entry{});
m_entries[index].Gen = 1;
// The high-water mark, at powers of two from 1024 up: at most a handful of lines
// for a whole session, emitted from the allocate-a-NEW-slot branch, i.e. once per
// object this backend has ever seen and never on a draw (ROADMAP.md:7).
//
// [narrow, declared deviation from D20's "MGLOG_D for anything non-critical"] This
// one is I, not D, because D is compiled out of every build that ships and of every
// build P2 measures, and this line IS the measurement review v2's MAJOR 1 asks for:
// the live-object high-water mark of minecraft-1.21.4-in-world and
// ...-sodium-in-world, which nothing on desktop reaches and no gate here can see.
// The structure no longer has a capacity to size off it, so the number is evidence
// rather than a tuning input - but D.4.2 should still read it out of the device log,
// and it cannot read a line that was compiled away.
const SizeT minted = m_entries.size();
if (minted >= 1024 && (minted & (minted - 1)) == 0) {
MGLOG_I("MagmaPipeIdentityTable(%s): high-water %zu slots minted, %u live",
m_kindName, minted, LiveCount());
}
return index;
}
const char* m_kindName = "";
Uint64 m_boundary = 0;
Vector<Entry> m_entries;
Vector<Uint32> m_freeSlots;
UnorderedMap<Uint64, Uint32> m_index;
// One-entry front memo (see Acquire). m_lastLifetimeId == 0 means "empty": a live
// object's lifetime id is never 0.
Uint64 m_lastLifetimeId = 0;
Uint32 m_lastIndex = 0;
MG_Pipe::MGPipeHandle m_lastHandle = MG_Pipe::kMGPipeNullHandle;
};
// The two mints one renderer owns. Per renderer, NOT process-global: two live contexts (or
// a context recreation, which destroys and rebuilds the renderer) would otherwise share one
// table and one reclamation clock, and both consumer tables are per-instance already.
class MagmaPipeIdentityTables {
public:
// A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array
// resolves to, and the only kind in MGPipeKind that names vertex-input state.
MG_Pipe::MGPipeHandle HandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
return kind == MG_Pipe::MGPipeKind::Buffer ? m_buffers.Acquire(lifetimeId)
: m_vaos.Acquire(lifetimeId);
}
void OnFrameBoundary() {
m_vaos.OnFrameBoundary();
m_buffers.OnFrameBoundary();
}
const MagmaPipeIdentityTable& Vaos() const { return m_vaos; }
const MagmaPipeIdentityTable& Buffers() const { return m_buffers; }
private:
MagmaPipeIdentityTable m_vaos{"VertexElementsCso"};
MagmaPipeIdentityTable m_buffers{"Buffer"};
};
// The table entry a handle names. Every per-slot table Magma keeps is indexed by this.
//
// A null handle has no slot, and it is unreachable here: both lifetime-id sources start at
// 1 (VertexArrayObject.cpp, BufferObject.cpp), so Acquire's zero guard never fires. The
// ternary, not the assertion, is what has effect in a shipped build (Defines.h compiles
// MOBILEGL_ASSERT out at INFO), and slot 0 of a consumer table is a real entry that a null
// handle can never match, because MGPipeHandleIsNull is also what the consumers compare.
inline Uint32 MagmaPipeSlotIndex(const MG_Pipe::MGPipeHandle& handle) {
MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle),
"a null MGPipeHandle has no slot to index a per-slot table with");
return MG_Pipe::MGPipeHandleIsNull(handle)
? 0u
: handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot;
}
// A grow-on-demand per-slot table whose ENTRY ADDRESSES NEVER MOVE.
//
// D12.4 asks for a grow-on-demand Vector, and with an unbounded mint that is what a
// consumer needs - but a Vector that grows relocates its elements, and the draw path holds
// references into these entries across nested calls. Chunks of kChunkEntries are appended
// instead: the Vector of owning pointers reallocates, the chunks never do, so an entry
// reference is valid for the life of the table. That is the same guarantee the fixed table
// it replaces gave, without the fixed capacity.
template <typename T, Uint32 kChunkEntries = 256>
class MagmaPipeSlotTable {
public:
T& operator[](Uint32 index) {
const Uint32 chunk = index / kChunkEntries;
while (m_chunks.size() <= chunk) {
m_chunks.push_back(MakeUnique<Chunk>());
}
return m_chunks[chunk]->Entries[index % kChunkEntries];
}
SizeT Capacity() const { return m_chunks.size() * kChunkEntries; }
private:
struct Chunk {
T Entries[kChunkEntries] = {};
};
Vector<UniquePtr<Chunk>> m_chunks;
};
// The claim rule every per-slot memo table uses, in one place so that the rule and the
// negative control that defeats it cannot drift apart between consumers - and so that the
// unit suite which drives a REAL slot reuse (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) tests
// this code rather than a copy of it.
//
// The SLOT picks the entry; the WHOLE handle - Gen included - decides whether the entry is
// this object's. A slot the mint recycled for a different object comes back with a moved
// Gen, so the compare fails and the entry is cleared rather than inherited. That is the
// half HandleRecycleScenario cannot reach (see MagmaPipeAbaControlDefeatsIdentity).
//
// With negative control C on, every object collapses onto one entry and the entry is handed
// back UNCLEARED and UNCLAIMED - at once "the replacement reproduced its predecessor's
// slot" and "the slot was reused and Gen did not move".
//
// `Memos` needs a MG_Pipe::MGPipeHandle member named Owner and a default constructor that
// means "empty"; VertexInputStateFactory::VaoBackendMemos is the one production instance.
template <typename Memos, Uint32 kChunkEntries>
inline Memos& MagmaPipeClaimSlotMemos(MagmaPipeSlotTable<Memos, kChunkEntries>& table,
const MG_Pipe::MGPipeHandle& handle) {
if (MagmaPipeAbaControlDefeatsIdentity()) {
return table[kMagmaPipeAbaControlSlotIndex];
}
Memos& memos = table[MagmaPipeSlotIndex(handle)];
if (!(memos.Owner == handle)) {
memos = Memos{};
memos.Owner = handle;
}
return memos;
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -7,6 +7,7 @@
// End of Source File Header
#include "VertexInputStateFactory.h"
#include "MagmaPipeArms.h"
#include "MG_Util/Converters/MGToStr/DataTypeConverter.h"
#include <MG_Backend/BackendObjects.h>
#include <utility>
@@ -45,25 +46,149 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous
// test's positions) instead of its own.
// Zero for client memory (no buffer), which is a distinct identity of its own.
const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
//
// P2 D12.4 / ARCHITECTURE.md 9.5: under the handle arm the identity is the
// buffer's {slot, gen} rather than its lifetime id - "lifetimeId -> gen mixed
// into every server-side content hash". The two are equally ABA-proof (the
// allocator maps one onto the other and bumps Gen only on slot REUSE); what
// changes is that the key is now the identity the SERVER will be handed once
// buffers travel as handles, instead of a number only the client can mint.
Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0;
#if MOBILEGL_PIPE_PUSH
if (attr.Buffer) {
// The SAME arm question the other four re-keyed sites ask, through the same
// helper: a site that decided for itself could silently key on the pre-handle
// identity while its neighbours keyed on the handle.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
const MG_Pipe::MGPipeHandle handle =
m_identity->HandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId());
bufferKey = static_cast<Uint64>(handle.Slot) | (static_cast<Uint64>(handle.Gen) << 32);
}
if (MagmaPipeAbaControlDefeatsIdentity()) {
// Negative control C (P2 brief D18), on WHICHEVER arm this run is on - the
// pre-handle lifetime id and the handle's {slot, gen} are the same guard
// wearing two hats, and a control that defeated only the retired one would
// say nothing about the key P2 ships.
//
// The identity is replaced by a constant rather than by the raw
// BufferObject*, because the address is not recycled in practice and so
// never collides (see MagmaPipeAbaControlDefeatsIdentity). Zero is what a
// key with NO buffer identity in it looks like - the exact defect this
// hash was fixed for: "the hash is what TryBindResolvedVertexBindings
// accepts as proof that a memoised binding still reads the buffer it was
// resolved from", and with the identity gone it accepts a binding resolved
// from a different buffer. HandleRecycleScenario.AbaControl then draws a
// replacement VAO and gets its dead predecessor's vertex data.
bufferKey = 0;
}
}
#endif
XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey)));
}
return XXH64_digest(m_hashState);
}
#if MOBILEGL_PIPE_PUSH
VertexInputStateFactory::VaoBackendMemos& VertexInputStateFactory::MemosFor(
const MG_State::GLState::VertexArrayObject& vao) const {
const MG_Pipe::MGPipeHandle handle =
m_identity->HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId());
// One entry per mintable slot, grown on demand: the mint has no capacity, so neither
// does this, and no two live VAOs can share an entry however large the working set is.
// There is no probe in front of it because the mint itself is one - a one-entry memo
// hit for every acquisition after this draw's first, and a hash probe otherwise.
//
// The claim rule - the slot picks the entry, the whole handle (Gen included) decides
// whose it is - and negative control C's defeat of it are MagmaPipeArms.h's
// MagmaPipeClaimSlotMemos, so that the unit suite which drives a REAL slot reuse
// (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) exercises this code and not a copy of it.
// What the control defeats HERE is the identity that SELECTS the entry: every VAO
// collapses onto one, handed back uncleared, so the replacement inherits the dead
// VAO's content hash and its resolved-entry pointer. The GENERATION half is the unit
// suite's business, for the reason MagmaPipeAbaControlDefeatsIdentity spells out.
return MagmaPipeClaimSlotMemos(m_vaoMemos, handle);
}
#endif
#if MOBILEGL_PIPE_PUSH
Bool VertexInputStateFactory::TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao,
Uint64& outHash) const {
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
const VaoBackendMemos& memos = MemosFor(vao);
if (memos.HashConfigVersion != vao.GetConfigVersion()) return false;
outHash = memos.Hash;
return true;
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
return vao.GetBackendHashMemo(outHash);
#else
return false;
#endif
}
#endif
VertexInputStateFactory::HashType VertexInputStateFactory::GetOrComputeHash(
const MG_State::GLState::VertexArrayObject& vao) const {
HashType hash = 0;
#if MOBILEGL_PIPE_PUSH
// P2 D12.5: the same memo, on the backend's side of the boundary.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
VaoBackendMemos& memos = MemosFor(vao);
if (memos.HashConfigVersion == vao.GetConfigVersion()) {
return memos.Hash;
}
hash = ComputeHash(vao);
memos.Hash = hash;
memos.HashConfigVersion = vao.GetConfigVersion();
return hash;
}
#endif
#if MOBILEGL_PIPE_LEGACY_MEMOS
if (!vao.GetBackendHashMemo(hash)) {
hash = ComputeHash(vao);
vao.SetBackendHashMemo(hash);
}
#endif
return hash;
}
const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState(
const MG_State::GLState::VertexArrayObject& vao) {
#if MOBILEGL_PIPE_PUSH
// P2 D12.5: the same per-draw fast path, but the resolved-entry pointer lives in this
// factory's slot-indexed table instead of on the frontend VAO. The eviction epoch
// survives the move and is still what stops a stale pointer being dereferenced: the
// POINTEE is a cache entry this factory can erase at a frame boundary, and moving the
// memo does not change that.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
VaoBackendMemos& memos = MemosFor(vao);
if (memos.StateConfigVersion == vao.GetConfigVersion() && memos.State != nullptr &&
memos.StateEpoch == m_evictionEpoch) {
const auto* memoEntry = static_cast<const BackendVertexInputState*>(memos.State);
memoEntry->lastUsedFrameBoundary = m_frameBoundaryCounter;
return *memoEntry;
}
const BackendVertexInputState& resolved =
GetOrCreateVertexInputState(vao, GetOrComputeHash(vao));
// MemosFor is re-taken rather than kept live across GetOrCreateVertexInputState:
// the reference is not worth holding across a call that can resize the table.
VaoBackendMemos& stamp = MemosFor(vao);
stamp.State = &resolved;
stamp.StateEpoch = m_evictionEpoch;
stamp.StateConfigVersion = vao.GetConfigVersion();
// The AUX memo is deliberately NOT stamped here: its two words already live in
// VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and its getter has no
// live reader anywhere, so the handle arm retires it rather than moving it.
return resolved;
}
#endif
#if !MOBILEGL_PIPE_LEGACY_MEMOS
// Unreachable: with no legacy arm compiled MagmaPipeTrackHArmIsHandles is a compile-
// time true, so the handle arm above always returns. Written out rather than left to
// fall off the end so the function still has a return on every path a compiler sees.
return GetOrCreateVertexInputState(vao, GetOrComputeHash(vao));
#else
// Per-draw fast path: the VAO carries a pointer to its resolved entry,
// valid while its config version and the cache's eviction epoch both
// match - no re-hash, no map lookup.
@@ -83,6 +208,7 @@ namespace MobileGL::MG_Backend::DirectVulkan {
vao.SetBackendAuxMemo(entry.layoutHash,
PackVertexInputAuxMasks(entry.unsupportedAttribMask, entry.attributeLocationMask));
return entry;
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
}
const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState(
@@ -316,8 +442,14 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Invalidate every VAO's state-pointer memo: the erased node's
// address may be reused by a future insert. Advance through the
// process-wide source so the value stays unique across factory
// instances (see the member comment).
// instances (see the member comment). With no legacy arm the memos
// live in this factory and die with it, so a per-instance bump is
// enough - P2 D12.5.
#if MOBILEGL_PIPE_LEGACY_MEMOS
m_evictionEpoch = ++s_evictionEpochSource;
#else
++m_evictionEpoch;
#endif
} else {
++it;
}
@@ -7,8 +7,12 @@
// End of Source File Header
#pragma once
// MG_Pipe::MGPipeHandle for the P2 D12.5 memo table below. A header of constexpr constants,
// so the pull build gains nothing from it.
#include <MG_Pipe/MGPipeHandles.h>
#include "Config.h"
#include "MagmaPipeArms.h"
#include "VertexInputStateBuilder.h"
#include "MG_State/GLState/VertexArrayState/VertexArrayObject.h"
#include <Includes.h>
@@ -70,8 +74,18 @@ namespace MobileGL::MG_Backend::DirectVulkan {
};
};
#if MOBILEGL_PIPE_PUSH
// The mint is the RENDERER's (MagmaPipeIdentityTables), not a process-global and not
// this factory's: VulkanRenderer::LookupVaoDrawMemo has to derive the same {slot, gen}
// for the same VAO, and a table that outlived the context it was minted for would share
// one reclamation clock across two live contexts (review v2 minor 4).
VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice,
MagmaPipeIdentityTables& identity):
m_config(config), m_physicalDevice(physicalDevice), m_identity(&identity) {}
#else
VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice):
m_config(config), m_physicalDevice(physicalDevice) {}
#endif
~VertexInputStateFactory() = default;
VertexInputStateFactory(const VertexInputStateFactory&) = delete;
@@ -86,6 +100,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Memoized ComputeHash: reuses the VAO's cached hash while its config version
// is unchanged. Use this on per-draw paths.
HashType GetOrComputeHash(const MG_State::GLState::VertexArrayObject& vao) const;
#if MOBILEGL_PIPE_PUSH
// The VAO's content hash IF it has already been memoized, without computing one.
// P2 D12.5: the three draw-path readers that used to ask the VAO object this
// question ask the factory instead, because that is where the memo lives once the
// frontend object stops carrying the backend's state.
Bool TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const;
#endif
const BackendVertexInputState& GetOrCreateVertexInputState(
const MG_State::GLState::VertexArrayObject& vao, HashType hash);
const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao);
@@ -112,6 +133,44 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static VkFormat ToFloat32VertexFormat(Int componentCount);
Bool SupportsVertexBufferFormat(VkFormat format) const;
#if MOBILEGL_PIPE_PUSH
// ---- P2 D12.5: the backend's memos, off the frontend VAO and into the backend ----
//
// The two facts that used to live as `mutable` fields on VertexArrayObject
// (Get/SetBackendHashMemo and Get/SetBackendStateMemo), kept here instead, keyed on
// the VAO's {slot, gen} and guarded by exactly the same config version. A frontend
// state object holding the backend's raw pointer is what P2 retires: under split the
// backend is in another process and its cache entry has no address a client could
// store, so the memo has to live on the side that owns the pointee.
//
// The AUX memo is not carried over: its two words moved into VaoDrawMemo::layoutHash
// and layoutAuxMasks long ago and its getter has no live reader anywhere in the tree,
// so the handle arm simply stops writing it (D12.5 says delete rather than move).
struct VaoBackendMemos {
// Whose memos these are. The identity table can recycle a slot for a different
// VAO under LRU pressure, and the handle compare - Gen included - is what says
// the contents are this object's and not its predecessor's.
MG_Pipe::MGPipeHandle Owner = MG_Pipe::kMGPipeNullHandle;
Uint64 Hash = 0;
Uint32 HashConfigVersion = ~0u;
const void* State = nullptr;
Uint64 StateEpoch = 0;
Uint32 StateConfigVersion = ~0u;
};
// Grow-on-demand (D12.4), one entry per slot the renderer's mint has ever handed
// out, and NO CAPACITY: these two memos had none before this package either - they
// were unbounded mutable fields on the VertexArrayObject itself - and re-introducing
// eviction here is what review v2 rejected. MagmaPipeSlotTable grows in chunks so an
// entry reference stays valid across the nested GetOrCreateVertexInputState call.
// 48 B per live VAO, reclaimed with the slot when the object goes idle.
mutable MagmaPipeSlotTable<VaoBackendMemos> m_vaoMemos;
// The renderer's {slot, gen} mint (see the constructor). Never null under push.
MagmaPipeIdentityTables* m_identity = nullptr;
// The entry belonging to `vao`, claimed (and cleared) if the slot currently holds
// someone else's.
VaoBackendMemos& MemosFor(const MG_State::GLState::VertexArrayObject& vao) const;
#endif
const VulkanRendererConfig& m_config;
VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE;
// Values are heap-allocated: UnorderedMap is open-addressing, so INSERT
@@ -137,8 +196,17 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// than anything a predecessor ever stamped, so a dead factory's memo can
// never compare equal here - the same never-reused idiom as the lifetime ids.
// Single-threaded like the rest of the factory (renderer-thread only).
//
// P2 D12.5: the process-wide source is the LEGACY arm's need. It exists because the
// memos live on the frontend VAOs and therefore outlive the factory. The handle arm's
// memo table is owned by this factory and dies with it, so a per-instance counter is
// enough there and the epoch shrinks back to what it looks like it should be.
#if MOBILEGL_PIPE_LEGACY_MEMOS
static inline Uint64 s_evictionEpochSource = 0;
Uint64 m_evictionEpoch = ++s_evictionEpochSource;
#else
Uint64 m_evictionEpoch = 1;
#endif
static inline XXH64_state_t* m_hashState = XXH64_createState();
};
} // namespace MobileGL::MG_Backend::DirectVulkan
@@ -397,6 +397,101 @@ namespace MobileGL::MG_Backend::DirectVulkan {
};
static DynamicStateShadow g_dynamicStateShadow;
#if MOBILEGL_PIPE_PUSH
// ---- D12.3: DynamicTailKey's inputs against the P2 chunk table ----
//
// DynamicTailKey's inventory (declared above, one line per reader) is an exact,
// hand-maintained enumeration of what the six Apply* in the tail read. The P2 chunk table
// (MG_Pipe/MGPipeRenderStateSpans.h) is an independent, offsetof-derived statement of
// which bytes of RenderStateParameters are dynamic state. The two were written for
// different reasons, so making them check each other is free evidence: if a later chunk
// edit demotes or promotes one of these members, the mismatch is a BUILD BREAK here rather
// than a tail that silently stops being re-run when its input moves.
//
// The brief (P2 D12.3) expects every input to be dynamic; the tree says otherwise for
// exactly one, and the tree is right - see the ScissorTestEnabledMask note below.
//
// These assertions ARE D19's DynamicChunksCoverMagmasDynamicTailKey, in the only file this
// package owns. D19 names it as a case in MG_Test/Pipe/RenderStateSpansTest.cpp, which
// belongs to package A (C.5). INTEGRATOR: make sure the outcome is not "neither" - if
// package A did not land that case, this static_assert block is the whole gate, and if it
// did, the two are redundant on purpose and both should stay.
namespace {
// Is [begin, begin + size) covered entirely by DYNAMIC chunks?
constexpr Bool MagmaRenderStateRangeIsDynamic(SizeT begin, SizeT size) {
const SizeT end = begin + size;
for (SizeT i = 0; i < MG_Pipe::kMGPipeRenderStateChunkCount; ++i) {
const SizeT chunkBegin = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i];
const SizeT chunkEnd = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i + 1];
if (end <= chunkBegin || begin >= chunkEnd) continue; // disjoint
if (MG_Pipe::MGPipeRenderStateChunkIsPipeline(i)) return false;
}
return true;
}
using MagmaTailRsp = RenderStateParameters;
#define MAGMA_TAIL_INPUT_IS_DYNAMIC(Member) \
static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, Member), \
sizeof(MagmaTailRsp::Member)), \
"ApplyDynamicDrawStateTail reads " #Member \
", which the P2 chunk table no longer calls dynamic state: a change to it would " \
"move the pipeline version, not the parameters version, and the tail would stop " \
"being re-run for it")
// Viewports, DepthRanges and ScissorBoxes are asserted over the WHOLE array while the
// tail reads only element 0. That is deliberately stricter than the reader needs: the
// chunk table has no per-element granularity today, so an array that is dynamic at all
// is dynamic entirely, and asserting the whole of it says so. If a later phase ever
// splits a per-viewport chunk out, this is a build break by design - narrow the assert
// to element 0 then, and say why in the same commit.
MAGMA_TAIL_INPUT_IS_DYNAMIC(Viewports); // ApplyGLViewportState: Viewports[0]
MAGMA_TAIL_INPUT_IS_DYNAMIC(DepthRanges); // ApplyGLViewportState: DepthRanges[0]
MAGMA_TAIL_INPUT_IS_DYNAMIC(BlendColor); // ApplyBlendConstants
MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetFactor); // ApplyPolygonOffsetState
MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetUnits); // ApplyPolygonOffsetState
MAGMA_TAIL_INPUT_IS_DYNAMIC(LineWidth); // ApplyLineWidthState
MAGMA_TAIL_INPUT_IS_DYNAMIC(ScissorBoxes); // the scissor rect: ScissorBoxes[0]
#undef MAGMA_TAIL_INPUT_IS_DYNAMIC
// ApplyStencilState reads three of the seven members of each face, and D6 splits
// StencilFaceState at sub-member granularity for exactly this reason: Ref, ValueMask
// and WriteMask are VK_DYNAMIC_STATE_STENCIL_{REFERENCE,COMPARE_MASK,WRITE_MASK}, while
// Func and the three ops are baked into the pipeline. Asserted per member, per face,
// because the split runs THROUGH the struct rather than around it.
constexpr SizeT kMagmaStencilFace1 = offsetof(MagmaTailRsp, StencilStates) + sizeof(StencilFaceState);
#define MAGMA_TAIL_STENCIL_IS_DYNAMIC(Member) \
static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, StencilStates) + \
offsetof(StencilFaceState, Member), \
sizeof(StencilFaceState::Member)), \
"ApplyStencilState reads the FRONT face's " #Member " as dynamic state"); \
static_assert(MagmaRenderStateRangeIsDynamic(kMagmaStencilFace1 + offsetof(StencilFaceState, Member), \
sizeof(StencilFaceState::Member)), \
"ApplyStencilState reads the BACK face's " #Member " as dynamic state")
MAGMA_TAIL_STENCIL_IS_DYNAMIC(Ref);
MAGMA_TAIL_STENCIL_IS_DYNAMIC(ValueMask);
MAGMA_TAIL_STENCIL_IS_DYNAMIC(WriteMask);
#undef MAGMA_TAIL_STENCIL_IS_DYNAMIC
// THE ONE INPUT THAT IS NOT DYNAMIC, and the brief's D12.3 says it should be.
// The tree wins, and it is right: the split's only rule is "a byte is pipeline state
// iff a public setter that calls BumpVersions() writes it", and ScissorTestEnabledMask
// is written by SetCapability(ScissorTest), which does. It sits in pipeline chunk P6
// with the other capability bools. The tail reads it only to decide between the
// scissor box and a full-extent rect, and it is HARMLESS there for a reason worth
// stating: a pipeline-half write moves the pipeline version, and the pipeline version
// moves only together with the parameters version (BumpVersions bumps both), so the
// tail's version gate is invalidated by it just the same. A DYNAMIC member promoted
// into the pipeline half would break that direction, which is what the asserts above
// are for; this one is pinned in the opposite direction so that DEMOTING it - which
// would be a real G7 violation - is also a build break.
static_assert(!MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, ScissorTestEnabledMask),
sizeof(MagmaTailRsp::ScissorTestEnabledMask)),
"ScissorTestEnabledMask is written by SetCapability(ScissorTest), which calls "
"BumpVersions(), so the chunk table must keep it in the pipeline half");
} // namespace
#endif // MOBILEGL_PIPE_PUSH
static void ResetDynamicStateShadow() {
g_dynamicStateShadow = {};
}
@@ -3015,6 +3110,13 @@ void main() {
}
void VulkanRenderer::Initialize() {
#if MOBILEGL_PIPE_PUSH
// P2 D14, and it belongs HERE rather than on a draw: "a Track-H subsystem whose bit is
// clear is a STARTUP Fatal{PipeLegacyMemosDisabled}". Checks Magma's own bit only, and
// only once this backend is the one being brought up, so an Espryt-side bitmask cannot
// kill a Magma run and vice versa.
MagmaPipeValidateSubsystemConfiguration();
#endif
CreateInstance();
CreateSurface();
PickPhysicalDevice();
@@ -3194,7 +3296,12 @@ void main() {
m_physicalDevice.properties.limits.minUniformBufferOffsetAlignment, m_config.MaxFramesInFlight,
maxProgramBindings, kDescriptorSetsPerFrame, m_textureManager.get(), m_samplerManager.get());
MOBILEGL_ASSERT(succeeded, "UniformDescriptorBinder initialization failed.");
#if MOBILEGL_PIPE_PUSH
m_vertexInputStateFactory =
MakeUnique<VertexInputStateFactory>(m_config, m_physicalDevice.handle, m_pipeIdentity);
#else
m_vertexInputStateFactory = MakeUnique<VertexInputStateFactory>(m_config, m_physicalDevice.handle);
#endif
MOBILEGL_ASSERT(m_vertexInputStateFactory != nullptr, "VertexInputStateFactory creation failed.");
// Prime the first frame so Render() always targets an acquired swapchain image.
@@ -3542,6 +3649,76 @@ void main() {
if (m_vaoDrawMemoTable.empty()) {
m_vaoDrawMemoTable.resize(kVaoDrawMemoSlotCount);
}
#if MOBILEGL_PIPE_PUSH
if (MagmaPipeAbaControlDefeatsIdentity()) {
// Negative control C (P2 brief D18), ahead of BOTH arms because it defeats the
// identity half of both keys at once: the legacy arm's (address, lifetime id) pair
// and the handle arm's {slot, gen}. Every VAO lands on one entry and the entry is
// handed back without an identity compare and WITHOUT being cleared - which is
// exactly what this table would do if a replacement object reproduced its dead
// predecessor's address, or reused its slot without the generation moving.
//
// Nothing else about the entry is relaxed: whether the resolved bindings it holds
// are then USED is still decided by TryBindResolvedVertexBindings' frame serial,
// content hash, active-attribute mask and slice epochs. That is what keeps the arm
// an assertion about identity rather than about the memo as a whole.
VaoDrawMemo& aliased = m_vaoDrawMemoTable[kMagmaPipeAbaControlSlotIndex];
aliased.vaoKey = vao;
aliased.vaoLifetimeId = vao->GetLifetimeId();
return &aliased;
}
// ---- P2 D12.4, the handle arm ----
//
// The slot PICKS the entry, and the handle DECIDES whether the entry is this VAO's -
// the same division of labour the legacy arm below gives the address and the lifetime
// id, with two differences that are both improvements:
//
// * the slot is dense from 1, so below kVaoDrawMemoSlotCount live slots the map is a
// bijection and the two-way probe never collides at all, where an address hash
// collides by the birthday rule from the first few dozen VAOs;
// * the handle is an exact identity - Gen moves whenever a slot changes owner - so
// neither a deleted VAO's successor at the same heap address nor a VAO whose slot
// was recycled can match a predecessor's entry, even byte-identically configured.
// That is what makes the lifetime-id half of the legacy compare unnecessary here.
//
// The capacity and the victim rule are deliberately the base ref's, unchanged: this is
// the one memo of the three that HAD a capacity before P2, and an entry lost to a
// collision costs exactly what it cost then (one vertex-binding re-resolve). Above
// kVaoDrawMemoSlotCount live VAOs a set of two ways serves four slots, and degrades
// from there - never worse than the address-hashed table it replaces, which was already
// colliding.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
const MG_Pipe::MGPipeHandle handle = ResolveVaoHandle(*vao);
const Uint32 index = MagmaPipeSlotIndex(handle) & (kVaoDrawMemoSlotCount - 1u);
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
if (first.vaoHandle == handle) {
return &first;
}
VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u];
if (second.vaoHandle == handle) {
return &second;
}
// Miss: recycle a slot. Prefer an unclaimed one; otherwise evict the entry whose
// bindings memo is older (its VAO is the one drawn less recently).
VaoDrawMemo* victim = &first;
if (!MG_Pipe::MGPipeHandleIsNull(first.vaoHandle) &&
(MG_Pipe::MGPipeHandleIsNull(second.vaoHandle) ||
second.bindings.frameSerial < first.bindings.frameSerial)) {
victim = &second;
}
victim->vaoHandle = handle;
victim->vaoKey = vao;
victim->vaoLifetimeId = vao->GetLifetimeId();
victim->contentHash = 0;
victim->layoutFactsValid = false;
// Unmatchable until a resolve completes (same rule as the legacy arm: a bailed-out
// resolve must never leave stale contents matchable).
victim->bindings.frameSerial = 0;
victim->bindings.indexFrameSerial = 0;
victim->bindings.indexBuffer = nullptr;
return victim;
}
#endif
// Multiplicative mix of the (16-byte-aligned) address; take high bits, they
// carry the most entropy of a multiply.
const Uint64 mixed = static_cast<Uint64>(reinterpret_cast<SizeT>(vao) >> 4) * 0x9E3779B97F4A7C15ull;
@@ -3551,6 +3728,13 @@ void main() {
// its own is recycled, and a slot matched on a recycled address hands the new VAO
// the dead one's resolved bindings.
const Uint64 lifetimeId = vao->GetLifetimeId();
// Negative control C has NO consumer here. It is answered once, ahead of both arms, by
// the early return above, so a run that reaches this line has the knob off and the
// lifetime-id half of the compare is unconditional. A fourth consumer here would be a
// second site deciding the same question - what MagmaPipeAbaControlDefeatsIdentity
// exists to prevent - and a trap: narrow that early return later and this one would
// silently return to D18's retired semantics. If it is ever narrowed, ask the accessor
// here rather than re-reading MG_Config::Features.
VaoDrawMemo& first = m_vaoDrawMemoTable[index];
if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) {
return &first;
@@ -3619,7 +3803,7 @@ void main() {
VaoDrawMemo* slot = nullptr;
ResolvedVertexBindings* memo = nullptr;
Uint64 vaoContentHash = 0;
const Bool vaoHashKnown = vao.GetBackendHashMemo(vaoContentHash);
const Bool vaoHashKnown = VaoContentHashIfKnown(vao, vaoContentHash);
if (vaoHashKnown) {
slot = LookupVaoDrawMemo(&vao);
memo = &slot->bindings;
@@ -4818,6 +5002,7 @@ void main() {
return MGB_CTX->GetRenderStateParameters().SampleMaskValue;
}
#if MOBILEGL_PIPE_LEGACY_MEMOS
Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount,
VkSampleCountFlagBits rasterizationSamples) const {
// One bulk fetch instead of ~17 per-field accessor calls into MG_State: every
@@ -4908,6 +5093,23 @@ void main() {
}
return hash;
}
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS
Uint64 VulkanRenderer::ComputePipelineSubsetStateHashFallback() const {
// The client's own hash, over the client's own definition of the pipeline subset - the
// seven pipeline chunks of the P2 chunk table, which is a strict SUPERSET of what
// ComputePipelineStateHash enumerated by hand. The render-pass facts it does not carry
// (colorAttachmentCount, the rasterization sample count, and through them the effective
// sample mask) are exactly the facts entry.renderPassHash separates, which is why the
// CSO handle can key this memo in the first place; this fallback inherits that argument
// unchanged.
//
// Only reached with no render-state CSO bound, and only in a build with no pre-handle
// arm to fall back to instead.
return MG_Pipe::MGPipeComputePipelineSubsetHash(MGB_CTX->GetRenderStateParameters());
}
#endif
// A program that runs a geometry shader AND captures transform feedback. Both halves are
// link-time properties, so this is safe to fold into a pipeline keyed on the program hash.
@@ -4983,6 +5185,25 @@ void main() {
// per-draw state flips (GL_BLEND toggles) would otherwise miss entries the memo holds.
// The version only guards recomputing the hash - unchanged version, unchanged bytes.
const Uint renderStateVersion = MGB_CTX->GetPipelineStateVersion();
#if MOBILEGL_PIPE_PUSH
// P2 D12.1. Non-null means the client's render-state CSO handle is this draw's state
// key and the hash below is not computed at all; null means the pre-handle arm. The
// two arms' entries can never match each other: the handle arm stores hash 0 and a
// real handle, the legacy arm a real hash and the null handle, and the probe compares
// both components.
const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso();
// Exactly one of the two state keys is live per draw, and the ternary short-circuits,
// so a draw on the handle arm neither hashes nor touches the fallback cache.
const Uint64 pipelineStateHash =
!MG_Pipe::MGPipeHandleIsNull(renderStateCso)
? 0
: ResolveFallbackPipelineStateHash(renderStateVersion,
renderPassEntry.colorAttachmentCount,
renderPassEntry.sampleCount);
#else
// THE PULL BUILD'S TEXT, statement for statement what the base ref has: G1 admits no
// resize of this function, and a helper the compiler merely inlines is not the same
// instruction schedule.
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount ||
m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) {
@@ -4994,12 +5215,16 @@ void main() {
m_pipelineStateHashValid = true;
}
const Uint64 pipelineStateHash = m_pipelineStateHash;
#endif
for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) {
const PipelineMemoEntry& entry = m_pipelineMemo[i];
if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode &&
entry.programHash == programObj.hash && entry.vertexInputHash == vertexLayoutHash &&
entry.renderPassHash == renderPassHash &&
entry.pipelineStateHash == pipelineStateHash &&
#if MOBILEGL_PIPE_PUSH
entry.renderStateCso == renderStateCso &&
#endif
entry.primitiveRestartEnable == primitiveRestartEnable &&
entry.transformFlags == transformFlags) {
if (MG_Util::PipeStats::Enabled()) {
@@ -5668,6 +5893,9 @@ void main() {
entry.vertexInputHash = vertexLayoutHash;
entry.renderPassHash = renderPassHash;
entry.pipelineStateHash = pipelineStateHash;
#if MOBILEGL_PIPE_PUSH
entry.renderStateCso = renderStateCso;
#endif
entry.primitiveRestartEnable = primitiveRestartEnable;
entry.transformFlags = transformFlags;
entry.pipeline = pipeline;
@@ -5917,6 +6145,21 @@ void main() {
// One compare for the whole tail: see the gate's declaration in
// DynamicStateShadow for why (version, extent, default-FBO flag) pins every
// input the six Apply* below read.
//
// P2 D12.3: this read is RE-SOURCED, not re-shaped. Under MOBILEGL_PIPE_PUSH the
// accessor no longer walks into GLContext's RenderState - it returns
// PipeInputs::m_renderStateParametersVersion, which the applier publishes from
// MGPDynamicState::Version (set_dynamic_state) and MGPBindRenderState::Version
// (bind_render_state). So the gate now reads what the client PUSHED.
//
// What it does NOT do, and the P2 brief expects it to, is stop moving on a
// pipeline-only change. The tree settles that against the brief: bind_render_state
// carries m_version too and the applier publishes it, and it has to - Espryt's
// SyncRenderState uses the very same counter as its all-state change detector and G5
// forbids touching it, so a bind that rewrote the pipeline half while leaving the
// counter still would make Espryt skip re-syncing the blend state it just changed.
// The second-level DynamicTailKey compare below is therefore what actually absorbs a
// pipeline-only change, exactly as it did before P2: one key build, no vkCmd*.
const Uint paramsVersion = MGB_CTX->GetRenderStateParametersVersion();
if (shadow.dynamicTailValid && shadow.dynamicTailParamsVersion == paramsVersion &&
shadow.dynamicTailExtentX == extent.x() && shadow.dynamicTailExtentY == extent.y() &&
@@ -6104,9 +6347,23 @@ void main() {
// draw of a VAO-cycling stream (Minecraft chunk rendering) through the full
// path, re-resolving descriptors and texture layouts nothing invalidated.
const auto& vao = *MGB_CTX->GetBoundVertexArray();
#if MOBILEGL_PIPE_PUSH
// P2 D12.4: the handle replaces the (address, lifetime id) pair here too - one
// compare instead of two, and the same identity the VAO draw memo is keyed on, so
// the two cannot disagree about whether "the VAO moved". The config version stays:
// it answers a different question (did this same object's layout change).
const Bool vaoMoved =
MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)
? (!(ResolveVaoHandle(vao) == snap.vaoHandle) ||
vao.GetConfigVersion() != snap.vaoConfigVersion)
: (static_cast<const void*>(&vao) != snap.vao ||
vao.GetLifetimeId() != snap.vaoLifetimeId ||
vao.GetConfigVersion() != snap.vaoConfigVersion);
#else
const Bool vaoMoved =
static_cast<const void*>(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId ||
vao.GetConfigVersion() != snap.vaoConfigVersion;
#endif
const auto& drawFbo =
MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject();
if (static_cast<const void*>(drawFbo.get()) != snap.drawFbo ||
@@ -6194,7 +6451,7 @@ void main() {
Uint64 auxMasks = 0;
Bool factsKnown = false;
Uint64 contentHash = 0;
if (vao.GetBackendHashMemo(contentHash)) {
if (VaoContentHashIfKnown(vao, contentHash)) {
const VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao);
if (vaoMemo->layoutFactsValid && vaoMemo->contentHash == contentHash) {
vaoLayoutHash = vaoMemo->layoutHash;
@@ -6211,7 +6468,7 @@ void main() {
auxMasks = VertexInputStateFactory::PackVertexInputAuxMasks(
vertexInputState.unsupportedAttribMask, vertexInputState.attributeLocationMask);
Uint64 stampedHash = 0;
if (vao.GetBackendHashMemo(stampedHash)) {
if (VaoContentHashIfKnown(vao, stampedHash)) {
VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao);
vaoMemo->contentHash = stampedHash;
vaoMemo->layoutHash = vaoLayoutHash;
@@ -6332,6 +6589,18 @@ void main() {
// what lets a per-draw GL_BLEND toggle alternate between two memo entries
// instead of missing forever on a monotonic version. A miss falls through
// to the full lookup.
#if MOBILEGL_PIPE_PUSH
// Same arm selector as GetOrCreatePipeline's probe (P2 D12.1); this site is the
// fast path's copy of it, and the two must key identically or the fast path would
// hand back a pipeline the full path would not have matched.
const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso();
const Uint64 pipelineStateHash =
!MG_Pipe::MGPipeHandleIsNull(renderStateCso)
? 0
: ResolveFallbackPipelineStateHash(renderStateVersion, snap.renderPassColorCount,
snap.renderPassSampleCount);
#else
// The pull build's text, statement for statement (see GetOrCreatePipeline).
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
m_pipelineStateHashColorCount != snap.renderPassColorCount ||
m_pipelineStateHashSampleCount != snap.renderPassSampleCount) {
@@ -6342,6 +6611,8 @@ void main() {
m_pipelineStateHashSampleCount = snap.renderPassSampleCount;
m_pipelineStateHashValid = true;
}
const Uint64 pipelineStateHash = m_pipelineStateHash;
#endif
const auto memoTransformFlags =
ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags);
for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) {
@@ -6349,7 +6620,10 @@ void main() {
if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode &&
entry.programHash == programObj.hash && entry.vertexInputHash == vaoLayoutHash &&
entry.renderPassHash == snap.renderPassHash &&
entry.pipelineStateHash == m_pipelineStateHash &&
entry.pipelineStateHash == pipelineStateHash &&
#if MOBILEGL_PIPE_PUSH
entry.renderStateCso == renderStateCso &&
#endif
entry.primitiveRestartEnable == drawPrimitiveRestartEnable &&
entry.transformFlags == memoTransformFlags) {
pipeline = entry.pipeline;
@@ -6383,6 +6657,15 @@ void main() {
snap.bindGeneration = bindGeneration;
snap.vao = static_cast<const void*>(&vao);
snap.vaoLifetimeId = vao.GetLifetimeId();
#if MOBILEGL_PIPE_PUSH
// Guarded by the SUBSYSTEM, not only by the build switch: with bit 6 clear the field
// is dead (vaoMoved takes the address/lifetime-id branch), and minting a handle for it
// would put this package's cost inside MOBILEGL_PIPE_PUSH=0 - the all-pull control arm
// D14 defines as reproducing P1 exactly, and the arm D.4.3's T2 is measured on.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
snap.vaoHandle = ResolveVaoHandle(vao);
}
#endif
snap.vaoConfigVersion = vao.GetConfigVersion();
snap.vaoLayoutHash = vaoLayoutHash;
snap.pipeline = pipeline;
@@ -6964,6 +7247,13 @@ void main() {
snap.programVersion = program.GetBackendStateVersion();
snap.vao = &vao;
snap.vaoLifetimeId = vao.GetLifetimeId();
#if MOBILEGL_PIPE_PUSH
// Subsystem-guarded for the same reason as the other stamping site: the field
// is dead with bit 6 clear, and MOBILEGL_PIPE_PUSH=0 has to be P1 exactly.
if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) {
snap.vaoHandle = ResolveVaoHandle(vao);
}
#endif
snap.vaoConfigVersion = vao.GetConfigVersion();
snap.drawFbo = drawFbo.get();
snap.drawFboLifetimeId = drawFbo->GetLifetimeId();
@@ -12524,6 +12814,14 @@ void main() {
if (m_vertexInputStateFactory) {
m_vertexInputStateFactory->OnFrameBoundary();
}
#if MOBILEGL_PIPE_PUSH
// Reclaim {slot, gen} for objects that have not been drawn for a long time, on the same
// cadence and the same retirement age as the entries those slots key. This is the
// stand-in for the frontend death notification P2 has no hook for, and it is what keeps
// the mint's footprint the LIVE working set rather than every object ever created
// (review v2 MAJOR 1 / MAJOR 3).
m_pipeIdentity.OnFrameBoundary();
#endif
if (m_samplerManager) {
m_samplerManager->OnFrameBoundary();
}
@@ -12889,6 +13187,9 @@ void main() {
InvalidateSetupDrawSnapshots();
}
m_vertexInputStateFactory->OnFrameBoundary();
#if MOBILEGL_PIPE_PUSH
m_pipeIdentity.OnFrameBoundary();
#endif
m_samplerManager->OnFrameBoundary();
auto& frame = m_frameContext.GetCurrent();
auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass();
@@ -9,6 +9,7 @@
#pragma once
#include "Config.h"
#include "FrameContext.h"
#include "MagmaPipeArms.h"
#include "PipelineFactory.h"
#include "ProgramFactory.h"
#include "SwapchainObject.h"
@@ -24,7 +25,13 @@
#include "MG_Util/Math/VectorTypes.h"
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Pipe/MGPipeHandles.h>
#include <MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h>
#if MOBILEGL_PIPE_PUSH
// The applier's CSO store: MGPipeApplier().BoundRenderStateCso is what the pipeline memo
// keys on after P2 (D12.1). Push-only, so the pull build's include graph is unchanged.
#include <MG_Pipe/PipeApply.h>
#endif
#include <vk_mem_alloc.h>
#include "../VkIncludes.h"
@@ -820,12 +827,31 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint64 programHash = 0;
Uint64 vertexInputHash = 0;
Uint64 renderPassHash = 0;
// VALUE hash of the pipeline-relevant fixed-function state (see
// ComputePipelineStateHash), not the monotonic pipeline-state version:
// the version never repeats, so a per-draw GL_BLEND toggle would miss
// all entries forever even though the state alternates between two
// values the memo already holds.
// The PRE-HANDLE arm's key component (P2 brief D12.1), and 0 in every entry the
// handle arm mints. VALUE hash of the pipeline-relevant fixed-function state (see
// ComputePipelineStateHash), not the monotonic pipeline-state version: the version
// never repeats, so a per-draw GL_BLEND toggle would miss all entries forever even
// though the state alternates between two values the memo already holds.
Uint64 pipelineStateHash = 0;
#if MOBILEGL_PIPE_PUSH
// The HANDLE arm's key component, and the whole of D12.1: the CLIENT already
// hashed the pipeline subset of RenderStateParameters and minted a content-
// addressed CSO for it (MG_Pipe/MGPipeRenderStateSpans.h, MG_Impl/Pipe/CsoCache),
// so re-hashing the same 396 bytes here was work the boundary had already done.
// Two draws share a CSO handle exactly when their pipeline bytes are equal, and
// the client's subset is a strict SUPERSET of what ComputePipelineStateHash read,
// so the handle discriminates at least as finely as the hash it replaces.
//
// renderPassHash STAYS beside it and is what keeps this key complete: the CSO
// carries GL state only, while colorAttachmentCount and the rasterization sample
// count - which ComputePipelineStateHash folded in through its signature and
// through ResolveEffectiveSampleMask - are render-pass facts that the render-pass
// hash already separates.
//
// Null in an entry minted by the legacy arm, so entries of the two arms can never
// match each other: the compare below tests BOTH components.
MG_Pipe::MGPipeHandle renderStateCso = MG_Pipe::kMGPipeNullHandle;
#endif
ProgramFactory::CompileOptionFlags transformFlags = {};
// Baked into the pipeline (PipelineFactory::ComputeHash mixes it), and NOT derivable
// from anything else in this key: it depends on whether the draw is indexed and on the
@@ -839,19 +865,142 @@ namespace MobileGL::MG_Backend::DirectVulkan {
PipelineMemoEntry m_pipelineMemo[kPipelineMemoSize];
Uint32 m_pipelineMemoCount = 0;
Uint32 m_pipelineMemoNext = 0;
// Hash of every fixed-function GL state the pipeline payload reads that the
// memo key's other fields (mode / program / vertex input / render pass /
// transform flags) do not already pin down. Equal hash under an equal rest
// of key => byte-identical PipelineCreatePayload. Cached per pipeline-state
#if MOBILEGL_PIPE_PUSH
// P2 D12.1's arm selector, and the whole of the pipeline memo's re-key. Returns the
// render-state CSO this draw is keyed on, or the null handle when the pre-handle arm
// is the one that runs.
//
// Under the handle arm the memo's state key IS this handle. The client hashed those
// 396 pipeline bytes when it minted the CSO (MGPipeComputePipelineSubsetHash), so
// recomputing an overlapping hash here was work the boundary had already done; the
// client's pipeline subset is a strict SUPERSET of what ComputePipelineStateHash read,
// so the handle discriminates at least as finely as the hash it replaces. What the
// handle does NOT carry is the render-pass side - colorAttachmentCount and the
// rasterization sample count, which ComputePipelineStateHash folded in through its
// signature and through ResolveEffectiveSampleMask - and that is exactly why
// entry.renderPassHash stays in the key beside it.
//
// The arm is live only when the render-state subsystem is migrated in this run AND the
// client has actually bound a 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
// delete_render_state clears the binding (MG_Pipe/PipeApply.cpp), so the null handle is
// reachable on any tree. Keying every draw on it would alias every render state onto
// one memo entry, so a null handle means "fall back to a state hash" - never an abort,
// and never a per-draw consultation of the legacy-memo lever: bit 0 is not a Track-H
// subsystem (D14 labels only bits 5 and 6 that), and the lever's Fatal is a STARTUP
// one, in MagmaPipeValidateSubsystemConfiguration.
//
// The fallback is warned ONCE rather than logged at debug, and that is deliberate: a
// silent fallback is what makes "the CSO arm never ran" easy to miss. W is compiled in
// at every shipped log level.
//
// The latch is a plain member bool, NOT MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL
// (MG_Util/Debug/Log.h) is an UNCONDITIONAL std::atomic_flag::test_and_set - a locked
// xchg, executed on every evaluation, not "one static bool test" as an earlier round of
// this comment claimed - and this site is on the per-draw pipeline path in the very
// configuration that reaches it (no tracker: every draw). ROADMAP.md:7 forbids leaving
// instrumentation on a hot path, so the once-ness is one non-atomic, always-predicted
// load of a member that is false exactly once. Single-threaded like the rest of the
// renderer, and per renderer rather than per process, which is also the right scope: a
// second context that never binds a CSO deserves to say so.
//
// What the absence of this warning from a run's log proves, EXACTLY: that no draw took
// the fallback WHILE bit 0 was set. With kMGPipeSubsystemRenderState clear the function
// returns before the latch, so absence proves nothing at all - and no draw is keyed on a
// handle either. Grep the mask out of the log beside it (review v2 minor 3).
//
// Push-only by construction: the pull build does not compile this function at all, so
// its two callers are statement-for-statement what they were (G1).
//
// [routed to the integrator, review v2 minor 11] MG_Pipe::MGPipeApplier() is ONE
// process-global applier (MG_Pipe/PipeApply.cpp), not the per-context CSO store D2
// specifies. In a multi-context process this reads whatever CSO another context last
// bound. The defect is package A's and the fix belongs there; Magma is its only P2
// consumer, so it is named here rather than left for both reviews to assume the other
// caught it.
MG_Pipe::MGPipeHandle ResolveBoundRenderStateCso() const {
if (!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) {
return MG_Pipe::kMGPipeNullHandle;
}
const MG_Pipe::MGPipeHandle boundCso = MG_Pipe::MGPipeApplier().BoundRenderStateCso;
if (MG_Pipe::MGPipeHandleIsNull(boundCso) && !m_pipelineCsoFallbackWarned) {
m_pipelineCsoFallbackWarned = true;
MGLOG_W("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is "
"bound; the pipeline memo is running on a state hash, not on the CSO "
"handle (no tracker on this build, or a draw between "
"delete_render_state and the next bind)");
}
return boundCso;
}
// Latch for the warning above. Mutable because the resolve is const and the latch is
// not part of the renderer's observable state.
mutable Bool m_pipelineCsoFallbackWarned = false;
// The memo key's STATE-HASH half, for a draw that has no CSO handle to key on: the
// pre-handle arm, and the fallback of D12.1's handle arm. Cached on the pipeline-state
// version plus the two render-pass facts the hash's inputs depend on, so an unchanged
// (version, colorAttachmentCount, sampleCount) proves the bytes are unchanged.
//
// [deviation from D12.1] The brief deletes this gate and its cached fields outright.
// They cannot go while a no-CSO draw is reachable - and it is, on any tree: a draw
// between delete_render_state and the next bind has no handle. On a tree whose tracker
// binds a CSO these five words are written once and never read again; they retire for
// real when the pull path does, at P13.
Uint64 ResolveFallbackPipelineStateHash(Uint renderStateVersion, Uint32 colorAttachmentCount,
VkSampleCountFlagBits rasterizationSamples) {
if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion ||
m_pipelineStateHashColorCount != colorAttachmentCount ||
m_pipelineStateHashSampleCount != rasterizationSamples) {
#if MOBILEGL_PIPE_LEGACY_MEMOS
m_pipelineStateHash =
ComputePipelineStateHash(colorAttachmentCount, rasterizationSamples);
#else
m_pipelineStateHash = ComputePipelineSubsetStateHashFallback();
#endif
m_pipelineStateHashVersion = renderStateVersion;
m_pipelineStateHashColorCount = colorAttachmentCount;
m_pipelineStateHashSampleCount = rasterizationSamples;
m_pipelineStateHashValid = true;
}
return m_pipelineStateHash;
}
#endif // MOBILEGL_PIPE_PUSH
#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS
// The same answer as ComputePipelineStateHash, computed from the P2 chunk table
// instead of from a hand-written field list, for the build that compiles no
// pre-handle arm (cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF). It is the CLIENT's own
// hash function - MGPipeComputePipelineSubsetHash over the 396 pipeline bytes - so a
// draw keyed on it and a draw keyed on a CSO handle are keyed on the same equivalence
// class of state, and the render-pass facts stay separated by renderPassHash either
// way. This is what makes the no-legacy build RUNNABLE rather than a configuration
// that aborts on the first draw that arrives without a CSO.
Uint64 ComputePipelineSubsetStateHashFallback() const;
#endif
#if MOBILEGL_PIPE_LEGACY_MEMOS
// THE PRE-HANDLE ARM (P2 brief D12.1 / D14). Hash of every fixed-function GL state the
// pipeline payload reads that the memo key's other fields (mode / program / vertex
// input / render pass / transform flags) do not already pin down. Equal hash under an
// equal rest of key => byte-identical PipelineCreatePayload. Cached per pipeline-state
// version: the version is monotonic and bumps on every pipeline-state
// change, so an unchanged (version, colorAttachmentCount) proves the state
// bytes are unchanged and the hash can be reused without re-reading them.
//
// The handle arm computes none of this: the client hashed the same bytes when it
// minted the CSO, so all five cached-hash members below exist only to avoid a
// re-hash the handle arm never performs.
Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount,
VkSampleCountFlagBits rasterizationSamples) const;
#endif
// The effective GL_SAMPLE_MASK word for a draw at this rasterization sample count; see
// the definition for the GL-vs-Vulkan rule it reconciles. Shared by the pipeline payload
// and the pipeline-state memo word so the two cannot disagree.
// and the pipeline-state memo word so the two cannot disagree. NOT part of the legacy
// arm: it is a PAYLOAD computation that depends on rasterizationSamples, so it survives
// the re-key and keeps reading Multisample / SampleMask / SampleMaskValue out of the
// working block.
Uint32 ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const;
// ResolveFallbackPipelineStateHash's cache. Written once and never read again on a
// build whose client binds a render-state CSO; see that function for why it survives
// the re-key at all.
Uint m_pipelineStateHashVersion = 0;
Uint32 m_pipelineStateHashColorCount = 0;
// The sample count the cached hash was computed at. A pipeline-state input now depends on
@@ -877,7 +1026,8 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// Drops every memoized pipeline handle. Required at command-buffer
// boundaries and whenever any pipeline may have been destroyed. Also drops
// the cached pipeline-state hash: the same boundaries can retire the GL
// context whose monotonic version the cache is keyed on.
// context whose monotonic version the cache is keyed on. The handle arm has no
// such cache to drop - a CSO handle is not derived from a monotonic version.
void InvalidatePipelineMemo() {
m_pipelineMemoCount = 0;
m_pipelineMemoNext = 0;
@@ -968,6 +1118,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// common shape), and "the VAO did not move" would then skip the layout
// re-resolve for a different VAO.
Uint64 vaoLifetimeId = 0;
#if MOBILEGL_PIPE_PUSH
// P2 D12.4: the handle arm's answer to the same question, and one compare rather
// than the pair above. Kept BESIDE them rather than replacing them because the
// pre-handle arm is still compiled (MOBILEGL_PIPE_LEGACY_MEMOS) and this snapshot
// is a value struct, not a wire type.
MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle;
#endif
Uint32 vaoConfigVersion = 0;
const void* drawFbo = nullptr;
// Never-reused lifetime id beside the raw pointer + Uint16 version: a
@@ -1242,6 +1399,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// - bindings revalidates per draw exactly as before (frame serial, content
// hash, per-binding live buffer pointers and slice epochs).
struct alignas(64) VaoDrawMemo {
#if MOBILEGL_PIPE_PUSH
// P2 D12.4: the handle arm's key, and the ONLY key it needs. {slot, gen} is an
// identity, so the pointer-plus-lifetime-id pair below stops being a key here;
// the slot also picks the table entry, so the address hash and the two-way probe
// go with it. Null in an entry that has never been claimed.
MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle;
#endif
const MG_State::GLState::VertexArrayObject* vaoKey = nullptr;
// The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer
// ALONE is not an identity: a deleted VAO's heap address is handed straight
@@ -1268,8 +1432,48 @@ namespace MobileGL::MG_Backend::DirectVulkan {
// fixed table also makes every VaoDrawMemo/ResolvedVertexBindings pointer
// stable for the duration of a draw, which the EBO memo handoff
// (m_currentDrawResolvedEntry) relies on.
//
// [deviation from D12.4, deliberate and narrow] The brief asks for a grow-on-demand
// Vector. This one stays FIXED at exactly the capacity and exactly the 2-way victim
// rule it has on the base ref, and only its KEY changes (a {slot, gen} handle instead
// of a hashed heap address plus a lifetime id). Two reasons, and the second is the
// whole of review v2's MAJOR 1:
// * a VaoDrawMemo is ~450 B (ResolvedVertexBindings dominates), so growing this
// table with the live VAO set is megabytes on a platform with an LMK, where the
// other two memos are 48 B and can afford it;
// * this is the ONLY one of the three memos that had a capacity before this package.
// Losing an entry here costs a vertex-binding re-resolve, exactly what losing it
// cost on the base ref, so at any working-set size this table is no worse than what
// it replaces - and strictly better below capacity, where the handle is a bijection
// with the slot and the two-way probe never collides at all. The other two memos
// (VertexInputStateFactory::m_vaoMemos) had NO capacity, so they keep having none.
static constexpr Uint32 kVaoDrawMemoSlotCount = 2048; // power of two
Vector<VaoDrawMemo> m_vaoDrawMemoTable;
#if MOBILEGL_PIPE_PUSH
// The renderer's {slot, gen} mint, shared with its VertexInputStateFactory so both
// derive the same handle for the same VAO. Per renderer, never a process-global: a
// global would share one table and one reclamation clock across two live contexts and
// outlive every one of them (review v2 minor 4).
MagmaPipeIdentityTables m_pipeIdentity;
// The VAO's {slot, gen}. A one-entry memo hit for every acquisition after a draw's
// first, so there is no second memo in front of it here.
MG_Pipe::MGPipeHandle ResolveVaoHandle(const MG_State::GLState::VertexArrayObject& vao) {
return m_pipeIdentity.HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso,
vao.GetLifetimeId());
}
#endif
// "Is this VAO's content hash already memoized?", asked of whichever side owns the
// memo (P2 D12.5). Force-inlined and defined in the class body so that the PULL
// build's three readers keep compiling to the very same two loads they always did -
// G1 admits no resize, and an out-of-line call here would be one.
[[gnu::always_inline]] inline Bool VaoContentHashIfKnown(
const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const {
#if MOBILEGL_PIPE_PUSH
return m_vertexInputStateFactory->TryGetMemoizedHash(vao, outHash);
#else
return vao.GetBackendHashMemo(outHash);
#endif
}
// Finds the slot holding `vao`, or recycles the older of its two candidate
// slots into an empty memo keyed on `vao`. Never returns null.
VaoDrawMemo* LookupVaoDrawMemo(const MG_State::GLState::VertexArrayObject* vao);
+7
View File
@@ -609,6 +609,13 @@ namespace MobileGL::MG_Pipe {
// The one door into the storage from the client side (MG_Impl/Pipe/PipeFill.cpp):
// the filler's per-field copies and stamps, and the verify snapshot.
friend struct MGPipeFillAccess;
// The other door, and the one that exists because of what this block IS after P2:
// the server's working RenderStateParameters. MG_Pipe/PipeApply.cpp scatters
// bind_render_state's and set_dynamic_state's chunks straight into m_renderState,
// which is why DirectGLES' SyncRenderState is not one line changed. It deliberately
// does NOT stamp the poison generations - a stamp says "the filler published this
// for THIS verb", which is the walk's statement, not the applier's.
friend struct MGPipeApplyAccess;
// ---- identity ----
const void* m_contextIdentity = nullptr;
+47 -1
View File
@@ -11,5 +11,51 @@ endif()
add_executable(DriverBench DriverBench.c)
target_link_libraries(DriverBench PRIVATE dl)
# WHY EVERY ENTRY HERE CARRIES A PASS_REGULAR_EXPRESSION.
#
# DriverBench prints one CSV row per case it ran and exits 0 whatever it ran. Before this, a ctest
# entry naming a case therefore could not answer the only question it exists to ask: an argument
# matching nothing in kBenchCases selected no case, printed only the header row, and still exited
# 0. DriverBench.c now refuses an unknown case name (exit 2), which closes it at the source - but
# the entry must be able to go red for the reason it exists WITHOUT depending on that check
# staying in the binary, so each entry also requires the case's own output row to appear.
#
# The regex is what a healthy run of that case prints and nothing else does: the case name at the
# start of a line, then the frames / ops-per-frame / median-ms / ns-per-op / fps columns
# (run_case()). A rename, a drop from kBenchCases, a boot_egl() failure or
# a crash part-way through the case all remove that row and turn the entry red.
#
# Note that a PASS_REGULAR_EXPRESSION makes ctest ignore the process exit code (cmCTestRunTest:
# success is `retVal == 0 || !RequiredRegularExpressions.empty()`), which is why the row itself
# has to be the evidence rather than a companion to the rc.
add_test(NAME DriverBench COMMAND DriverBench draw_tiny)
set_tests_properties(DriverBench PROPERTIES LABELS benchmark)
# draw_tiny's a/ops scale with $DRIVERBENCH_DRAWS (main()), so only the shape of
# the row is pinned here, not the column values.
set_tests_properties(DriverBench PROPERTIES
LABELS benchmark
PASS_REGULAR_EXPRESSION "(^|\n)draw_tiny,[0-9]+,[0-9]+,[0-9.]+,[0-9.]+,[0-9.]+")
# The Blaze3D blend toggle, as its own entry.
#
# mc_state_toggle is glEnable(GL_BLEND) / glBlendFuncSeparate / glDrawElements /
# glDisable(GL_BLEND) / glDrawElements, 46 times - the measured vanilla-frame rate, and the exact
# shape ROADMAP.md writes down as the microbenchmark P2 owes the GO/NO-GO. It is the workload the
# whole "push at validate, not in the setter" decision was made for: a per-setter design pays for
# every toggle, and a CSO that is minted twice and then reused pays for none of them.
#
# The case has existed in kBenchCases since P0 and nothing ran it, so nothing noticed if it broke.
# Exposing it costs about 1.2 s inside an existing three-minute job, and it means the number the
# P2 report quotes comes from a case CI has been executing all along rather than from a code path
# whose first run is the day it is measured.
#
# Like the entry above, this runs against whatever $DRIVERBENCH_EGL_LIB names (the system driver
# when unset) - the ctest entry is a "does this case still run" gate, not the measurement. The
# measurement is run_driver_bench.sh against each of {native, espryt, magma}.
add_test(NAME DriverBenchStateToggle COMMAND DriverBench mc_state_toggle)
# The ops-per-frame column is pinned to 46 here, unlike the entry above: the mc_* cases are
# excluded from the $DRIVERBENCH_DRAWS scaling on purpose ("the mc_* rates are measured and must
# not move, or the numbers stop being comparable", main()), so 46 toggles per frame
# is part of what "this case still runs" means. Change the workload and this entry says so.
set_tests_properties(DriverBenchStateToggle PROPERTIES
LABELS benchmark
PASS_REGULAR_EXPRESSION "(^|\n)mc_state_toggle,[0-9]+,46,[0-9.]+,[0-9.]+,[0-9.]+")
@@ -476,6 +476,28 @@ int main(int argc, char** argv) {
if (getenv("DRIVERBENCH_FRAMES")) g_frames = atoi(getenv("DRIVERBENCH_FRAMES"));
if (getenv("DRIVERBENCH_SPRITES")) g_mixSprites = atol(getenv("DRIVERBENCH_SPRITES"));
/* A requested case name that matches nothing used to select nothing, print the header row and
* exit 0 - so a caller that names a case (run_driver_bench.sh, and the two ctest entries in
* CMakeLists.txt) could not tell "the case ran" from "the case has been renamed or deleted".
* Refuse it here, before any GL work, so the refusal reaches a caller that has no display
* either, and name what does exist so the fix is obvious. */
int unknownCases = 0;
for (int j = 1; j < argc; ++j) {
int known = 0;
for (int i = 0; i < kBenchCaseCount; ++i)
if (strcmp(argv[j], kBenchCases[i].name) == 0) known = 1;
if (!known) {
fprintf(stderr, "DriverBench: no case named '%s'\n", argv[j]);
unknownCases = 1;
}
}
if (unknownCases) {
fprintf(stderr, "DriverBench: the %d cases in kBenchCases are:\n", kBenchCaseCount);
for (int i = 0; i < kBenchCaseCount; ++i)
fprintf(stderr, " %s\n", kBenchCases[i].name);
return 2;
}
if (boot_egl()) return 1;
build_resources();
@@ -659,6 +659,7 @@ namespace MobileGL::MG_Impl::GLImpl {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MGP_SET_BASE_INSTANCE(baseinstance);
MGP_FILL(DrawElementsInstancedBaseVertexBaseInstance);
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertexBaseInstance(
mode, count, type, indices, instancecount, basevertex, baseinstance);
@@ -681,6 +682,7 @@ namespace MobileGL::MG_Impl::GLImpl {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MGP_SET_BASE_INSTANCE(baseinstance);
MGP_FILL(DrawElementsInstancedBaseInstance);
MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseInstance(mode, count, type, indices,
instancecount, baseinstance);
@@ -710,6 +712,7 @@ namespace MobileGL::MG_Impl::GLImpl {
ZoneScopedC(TRACY_ZONECOLOR_BACKEND);
#endif
if (ConditionalRenderDiscardsCommand()) return;
MGP_SET_BASE_INSTANCE(baseinstance);
MGP_FILL(DrawArraysInstancedBaseInstance);
MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstancedBaseInstance(mode, first, count, instancecount,
baseinstance);
+202
View File
@@ -0,0 +1,202 @@
// MobileGL - MobileGL/MG_Impl/Pipe/CsoCache.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// The render-state CSO cache (ARCHITECTURE.md 4.5.2 / 5.3, P2 brief D7).
//
// THE LOOKUP, and the first step is the whole point:
// 1. m_pipelineStateVersion (widened) did not move -> reuse the last handle. ZERO hashing,
// zero probing, and nothing is emitted unless m_version also moved. That is the steady
// state of every frame, and it is why the tracker asks the cache at all only when the
// dirty walk says the pipeline version moved.
// 2. moved -> hash the 396 pipeline bytes, probe, and on a hit CONFIRM WITH A MEMCMP
// before reusing the handle. ARCHITECTURE.md 4.1 says content addressing on an
// xxHash; a bare 64-bit 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.
// Mesa's cso_cache memcmps for the same reason. The memcmp only ever runs on a
// pipeline-version change, i.e. never in the steady state.
// 3. miss -> mint a slot, emit create_render_state with every pipeline chunk, then bind.
//
// CAPACITY 64 (ROADMAP.md P2). 64 x (8 + 8 + 396 + 8) = about 26 KB per context. ROADMAP.md
// open question 4 says 64 is provisional and the counters retune it at P13; this ships 64
// and publishes the mint / bind / evict counters that retune reads.
//
// THE NEGATIVE CONTROL. kMGPipeBehaviourNoCsoContentAddressing (bit 63 of the runtime
// MOBILEGL_PIPE_PUSH bitmask) turns off the PROBE and the handle reuse, not the records:
// every pipeline-version change then mints a fresh CSO, binds it and evicts, which is
// precisely "whole-block content addressing" and reproduces the regression
// RenderState.h records. It is what separates "push is slower" from "the CSO design is
// slower", and CsoContentAddressingScenario (package E) is the always-on ctest that stops
// the switch from rotting.
//
// Header-only for the same ownership reason as Tracker.h: the root CMakeLists.txt that
// would name a new .cpp is package A's and is frozen behind the p2/contract tag.
#if MOBILEGL_PIPE_PUSH
#include <Config.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <cstring>
namespace MobileGL::MG_Pipe {
inline constexpr SizeT kMGPipeCsoCacheCapacity = 64;
class MGPipeCsoCache {
public:
struct Counters {
Uint64 Mints = 0; // create_render_state emissions
// bind_render_state emissions, mint or reuse. Counted in Acquire because Acquire
// has exactly ONE caller (PipeFill.cpp's EmitRenderState) and that caller binds
// immediately after every call - so "acquisitions" and "binds" are the same
// number, and counting it here keeps the count from depending on an emitter
// remembering to tick it. mints/binds is the cache's hit rate and it is the
// number the CSO content-addressing negative control moves.
Uint64 Binds = 0;
Uint64 Hits = 0; // a probe that found a live entry and passed the memcmp
Uint64 Collisions = 0; // a hash hit the memcmp REJECTED - the reason it exists
Uint64 Evictions = 0; // LRU evictions, each one a delete_render_state
};
// The handle for `params`' pipeline subset. Mints and emits create_render_state on a
// miss; emits delete_render_state for whatever it evicts to make room. `payloadBytes`
// accumulates what went on the wire, for PipeStats::RecordDrawPayloadBytes.
MGPipeHandle Acquire(const RenderStateParameters& params, Uint64& payloadBytes) {
Array<Uint8, kMGPipePipelineChunkBytes> bytes;
MGPipeGatherPipelineBytes(params, bytes.data());
++m_counters.Binds;
const Bool contentAddressed =
(MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0;
if (contentAddressed) {
const Uint64 hash = s_hashForTest != nullptr ? s_hashForTest(bytes.data())
: MGPipeHashPipelineBytes(bytes.data());
for (SizeT i = 0; i < m_entries.size(); ++i) {
if (m_entries[i].Hash != hash) continue;
if (std::memcmp(m_entries[i].Bytes.data(), bytes.data(), bytes.size()) != 0) {
// A 64-bit collision between two DIFFERENT render states. Reusing the
// handle here would render one state with the other's pipeline, so the
// entry is dropped and the caller mints - correctness first, and the
// counter says how often it happened.
++m_counters.Collisions;
Evict(i);
break;
}
m_entries[i].LastUsed = ++m_clock;
++m_counters.Hits;
return m_entries[i].Cso;
}
return Mint(hash, bytes, payloadBytes);
}
// Content addressing OFF: never probe, always mint. The records still exist, so
// the arm differs from the default one in exactly one thing - whether a handle is
// reused - which is what makes it a control rather than a different design.
return Mint(0, bytes, payloadBytes);
}
// Context teardown, a server reset, a unit test's fixture. Emits nothing: the applier
// is reset alongside, and a delete for a record that is about to be dropped anyway
// would be a wire message with no reader.
void Reset() {
for (auto& entry : m_entries) MGPipeSlots().Free(MGPipeKind::RenderStateCso, entry.Cso);
m_entries.clear();
m_clock = 0;
}
void ResetCounters() { m_counters = Counters{}; }
SizeT Size() const { return m_entries.size(); }
const Counters& GetCounters() const { return m_counters; }
// TEST SEAM, and it is here because the thing it tests cannot be reached any other
// way. A 64-bit collision between two DIFFERENT render states is silent wrong pixels
// and it is exactly what the memcmp confirm above exists to stop, so
// CsoCacheTest.HashCollisionDoesNotAliasTwoStates has to be able to make one happen.
// Null in every real build - one never-taken, perfectly-predicted branch on a path
// that runs only when the pipeline version moved, i.e. never in the steady state.
using HashForTestFn = Uint64 (*)(const void* pipelineBytes);
inline static HashForTestFn s_hashForTest = nullptr;
private:
struct Entry {
Uint64 Hash = 0;
Uint64 LastUsed = 0;
MGPipeHandle Cso = kMGPipeNullHandle;
Array<Uint8, kMGPipePipelineChunkBytes> Bytes{};
};
MGPipeHandle Mint(Uint64 hash, const Array<Uint8, kMGPipePipelineChunkBytes>& bytes,
Uint64& payloadBytes) {
if (m_entries.size() >= kMGPipeCsoCacheCapacity) {
SizeT victim = 0;
for (SizeT i = 1; i < m_entries.size(); ++i) {
if (m_entries[i].LastUsed < m_entries[victim].LastUsed) victim = i;
}
Evict(victim);
}
const MGPipeHandle cso = MGPipeSlots().Allocate(MGPipeKind::RenderStateCso);
MGPRenderStateDesc desc{};
desc.Cso = cso;
desc.BaseCso = kMGPipeNullHandle;
// A brand-new CSO names every pipeline chunk; the incremental form against a
// BaseCso is what the applier's assertion allows and P3 will use once a CSO is
// minted from a neighbour rather than from nothing.
desc.ChunkMask = kAllPipelineChunks;
desc.Blob.Size = kMGPipePipelineChunkBytes;
MGPipeApplyCreateRenderState(desc, bytes.data());
payloadBytes += sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes;
Entry entry;
entry.Hash = hash;
entry.LastUsed = ++m_clock;
entry.Cso = cso;
entry.Bytes = bytes;
m_entries.push_back(entry);
++m_counters.Mints;
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoMints, 1);
}
return cso;
}
void Evict(SizeT index) {
MGPHandleOnly handle{};
handle.Handle = m_entries[index].Cso;
handle.Kind = static_cast<Uint32>(MGPipeKind::RenderStateCso);
MGPipeApplyDeleteRenderState(handle);
MGPipeSlots().Free(MGPipeKind::RenderStateCso, m_entries[index].Cso);
m_entries[index] = m_entries.back();
m_entries.pop_back();
++m_counters.Evictions;
}
static constexpr Uint32 kAllPipelineChunks =
static_cast<Uint32>((Uint64{1} << kMGPipePipelineChunkCount) - 1);
Vector<Entry> m_entries;
Uint64 m_clock = 0;
Counters m_counters;
};
// The monolith's one cache, held beside the tracker. A Vector scan rather than a hash
// map on purpose: 64 entries of Uint64 is a handful of cache lines, it is probed only
// when the pipeline version moved, and it keeps the eviction order in the same array as
// the content - a map would need a second structure to answer "which is oldest".
inline MGPipeCsoCache& MGPipeCsoCacheInstance() {
static MGPipeCsoCache cache;
return cache;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
File diff suppressed because it is too large Load Diff
+75 -6
View File
@@ -18,11 +18,21 @@
namespace MobileGL::MG_Pipe {
struct PipeInputs;
// PipeFill.cpp. Bumps the per-verb serial, records the verb and the context identity,
// and copies every field in the verb class's may-read mask (kMGPipeClassFieldMask) out
// of the live GLContext, stamping each with the new serial. In a verify build it then
// runs the entry compare against a second snapshot (P1 brief D8).
void MGPipeFillForVerb(MGPipeVerb verb);
// PipeFill.cpp. THE VALIDATE POINT (ARCHITECTURE.md 5.1, P2 brief D1). In order:
// 1. bump the per-verb serial, record the verb and the context identity;
// 2. run the tracker's DIRTY WALK for this verb's class (MG_Impl/Pipe/Tracker.h);
// 3. EMIT, for each set dirty bit whose subsystem bit is on in the runtime
// MOBILEGL_PIPE_PUSH bitmask, the P2 call that carries it;
// 4. run the P1 residual fill for every field an emitted call did NOT supply,
// stamping each with the new serial exactly as before;
// 5. in a verify build, the entry compare against a second snapshot (P1 brief D8) -
// which stops being a tautology the moment step 3 supplies a field step 4 skips.
//
// It was MGPipeFillForVerb through P1, when steps 2 and 3 did not exist. The macro
// spelling, the 83 call sites and the verb enum are unchanged: 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.
void MGPipeValidateForVerb(MGPipeVerb verb);
// Ends the verb in flight without starting another: bumps the serial, so every field the
// verb stamped goes stale, and puts the current verb back to "none", so a read made after
@@ -33,6 +43,36 @@ namespace MobileGL::MG_Pipe {
// stop where it says it stops (MG_Test/ScopedPipeVerb.h).
void MGPipeLeaveVerb();
// PipeFill.cpp. P3a D-H2.1: the DRAW's raw vertex-fetch base instance, which
// set_vertex_buffers now carries as an explicit field.
//
// It replaces an ambient process global the backend read at VAO sync time, which is a
// shape that cannot cross a pushed boundary. The client sends the raw value and never a
// pre-shifted offset: whether to emulate the fetch shift or let GL_EXT_base_instance do
// it is the SERVER's decision. It is also an input to set_vertex_buffers' content hash
// and to the tracker's bit-9 shutter, so a draw whose only change is its base instance
// still reaches the emitter and still goes out.
//
// DO NOT CALL IT DIRECTLY FROM A GL ENTRY POINT - use MGP_SET_BASE_INSTANCE below. This
// whole declaration block is inside #if MOBILEGL_PIPE_PUSH, so a bare call would not even
// compile in a pull build, and the three call sites are in a file that is compiled in
// both. The macro is the same shape MGP_FILL already has, for the same reason.
//
// The validate point consumes and clears it - on both of its exits - and MGPipeLeaveVerb
// clears it too, so a plain draw that follows a base-instanced one sees 0 again. The
// tracker's Reset() deliberately does NOT clear it (Tracker.h): a make-current happens
// BETWEEN the setter and the fill that reads it.
//
// The three GL entry points that make this call (ID-10's grant) are
// MG_Impl/GLImpl/Drawing/GL_Drawing.cpp's DrawElementsInstancedBaseVertexBaseInstance,
// DrawElementsInstancedBaseInstance and DrawArraysInstancedBaseInstance - one line each,
// immediately above the MGP_FILL, carrying the RAW baseinstance argument.
void MGPipeSetPendingBaseInstance(Uint32 baseInstance);
// What the next set_vertex_buffers will carry. The unit gate reads it to pin that a
// make-current between the setter and the fill does not eat it
// (TrackerWalk.ABaseInstanceSurvivesTheFirstWalkOnAFreshContext).
Uint32 MGPipePendingBaseInstance();
// PipeFill.cpp. Negative control B (P1 brief D6): the filler withholds the STAMP - never
// the value - of `field` at `verb`, so that verb's read of it is
// Fatal{UnmigratedPipeInput, "Field@Verb"} while every other verb is unaffected. The
@@ -41,6 +81,28 @@ namespace MobileGL::MG_Pipe {
// Fatal{PipeVerifyBadKnob}.
void MGPipeSetPoisonOmission(const char* verb, const char* field);
// PipeFill.cpp. How many times set_vertex_attrib_defaults' applier failed to reproduce
// the value the call carried, so the client wrote the mirror itself
// (EmitVertexAttribDefaults). It is the ONE observable of that repair: the window it
// covers is a verb whose class does not read m_currentVertexAttribute, where reading the
// storage to check it would be the poison violation the fill table exists to forbid. So
// TrackerShippedEmitter asserts on this counter instead, and the day package A's applier
// switches on MGPAttribValue::ValueClass the counter stops moving.
//
// Not hot-path instrumentation: it is incremented only inside the repair branch, which
// runs only when the call actually went out, which is only when an attribute default
// moved.
Uint64 MGPipeVertexAttribDefaultRepairCount();
// PipeFill.cpp. The header of the last set_vertex_attrib_defaults that actually went out
// - Mask, and Count == 0 for "none ever did", since a call naming no attribute is not
// emitted. Two properties of this call have no other observable, because reading
// m_currentVertexAttribute back at a verb whose class does not carry it is the poison
// violation the fill table exists to forbid: that a FRESH CONTEXT republishes all 32
// (the server's mirror still holds the previous context's defaults), and that one moved
// attribute publishes exactly one. Eight bytes, written only when a call goes out.
MGPVertexAttribDefaults MGPipeVertexAttribDefaultsLastHeader();
#if MOBILEGL_PIPE_VERIFY
// PipeFill.cpp. The second arm of the comparator (P1 brief D8, ARCHITECTURE.md 13.2-2):
// fills `snapshot` from the live GLContext the old way, for every field in `mask`. This
@@ -49,7 +111,14 @@ namespace MobileGL::MG_Pipe {
void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask);
#endif
} // namespace MobileGL::MG_Pipe
#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeFillForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb)
#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeValidateForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb)
// P3a D-H2.1. One line immediately ABOVE the MGP_FILL of a draw entry point that takes a
// baseinstance, carrying the argument RAW. It has to be a macro for MGP_FILL's reason: the
// three call sites are compiled in the pull build too, where MGPipeSetPendingBaseInstance is
// neither declared nor defined.
#define MGP_SET_BASE_INSTANCE(BaseInstance) \
::MobileGL::MG_Pipe::MGPipeSetPendingBaseInstance(static_cast<::MobileGL::Uint32>(BaseInstance))
#else
#define MGP_FILL(Verb) ((void)0)
#define MGP_SET_BASE_INSTANCE(BaseInstance) ((void)0)
#endif
+629
View File
@@ -0,0 +1,629 @@
// MobileGL - MobileGL/MG_Impl/Pipe/ResourceTracker.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// The CLIENT side of P3a's resource family (brief D-A, D-B, D-C, D-D).
//
// WHERE IT RUNS, and it is the ONE exception to push-at-validate (ARCHITECTURE.md 5.1):
// the seven BufferBackendOps hooks already dispatch at the GL call that causes them, so
// their pipe calls are emitted from the same BufferObject dispatchers - not from
// MGPipeValidateForVerb. Nothing about buffers moves to validate time in P3a.
//
// WHAT LIVES HERE
// * the sticky BindMask, one constexpr BufferTarget -> bit table with a static_assert
// that it covers every enumerator, so a new target cannot be silently unmapped;
// * the lifetimeId -> {slot, gen} mint (through MGPipeSlots(), the one allocator) and
// the slot -> BufferObject* INVERSE the reverse channel resolves a writeback through;
// * the nine MGPipeEmitResource* bodies, declared in MG_Pipe/PipeMutation.h so that
// MG_State sees a declaration and never this file (the same layering PipeMutation.h
// already has for MGP_NOTE_MUTATION: declare in MG_Pipe, define in MG_Impl);
// * the MGPSubData range splitter, because one record's box caps the destination at a
// 2^31-1 offset and a 2^32-1 size;
// * the map-persistent-roundtrips counting site.
//
// HEADER-ONLY, for the ownership reason Tracker.h states in full: the root CMakeLists.txt
// that would name a new .cpp belongs to the contract package and is frozen behind the tag.
// MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library.
//
// NO TIMER, and no per-call record copy on a HOT path. The two observables a unit case
// needs - the last emitted descriptor and the per-call counts - are written only by
// resource_create and resource_respecify, which run once per glBufferData rather than per
// upload; resource_subdata, the hot one, is observed through the pure builders below
// instead (MGPipeBuildSubDataRecord / MGPipeForEachSubDataRecordRange), which is also what
// lets a test drive the splitter at both of its bounds without a 4 GiB buffer.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_State/GLState/BufferState/BufferState.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <Config.h>
#include <cstdint>
namespace MobileGL::MG_Pipe {
// ---------------------------------------------------------------------------------
// D-A3: BindMask
// ---------------------------------------------------------------------------------
// MGPResourceDesc::BindMask's twelve bits, in the order MGPipeTypes.h names them:
// VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET|
// DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY.
//
// They are spelled HERE rather than in MGPipeTypes.h because that header is the contract
// package's and the mask has, so far, exactly one producer: this file. The integrator
// moves them beside the field when a second producer appears (P4a's texture family).
enum MGPipeBindBit : Uint16 {
kMGPipeBindNone = 0,
kMGPipeBindVertex = 1u << 0,
kMGPipeBindIndex = 1u << 1,
kMGPipeBindConstant = 1u << 2,
kMGPipeBindShaderBuffer = 1u << 3,
kMGPipeBindIndirect = 1u << 4,
kMGPipeBindSampler = 1u << 5,
kMGPipeBindShaderImage = 1u << 6,
kMGPipeBindRenderTarget = 1u << 7,
kMGPipeBindDepthStencil = 1u << 8,
kMGPipeBindStreamOutput = 1u << 9,
kMGPipeBindAtomic = 1u << 10,
// THE D-B7 SWITCH. With kCapNeedsHostIndexBytes set the server mirrors this
// resource's bytes so it can rewrite restart indices and flatten multi-draws
// (ARCHITECTURE.md 10.3). Getting it wrong is invisible in monolith and silently
// disables both under split, which is why it is set from the same table as every
// other bit rather than from a special case at the emission site.
kMGPipeBindElementArray = 1u << 11,
};
// A sentinel the table below returns for an enumerator it does not name. It is NOT a
// legal mask value: every enumerator must be listed, including the ones that map to no
// bit at all, so that ADDING a BufferTarget is a build break here rather than a bit
// that silently stops being published.
inline constexpr Uint32 kMGPipeBindUnmapped = 0x10000u;
// The one table. No `default:` arm on purpose - that is what makes the static_assert
// below able to see an unnamed enumerator.
constexpr Uint32 MGPipeBindMaskForBufferTarget(BufferTarget target) {
switch (target) {
case BufferTarget::Vertex:
return kMGPipeBindVertex;
// GL_ELEMENT_ARRAY_BUFFER is the VAO's element slot: the same bind is both "this
// resource is an index buffer" and "the server may need its bytes on its own side".
case BufferTarget::Index:
return kMGPipeBindIndex | kMGPipeBindElementArray;
case BufferTarget::Uniform:
return kMGPipeBindConstant;
case BufferTarget::ShaderStorage:
return kMGPipeBindShaderBuffer;
case BufferTarget::DispatchIndirect:
case BufferTarget::DrawIndirect:
case BufferTarget::Parameter:
return kMGPipeBindIndirect;
// A texture buffer's backing store is SAMPLED through the texture that names it.
case BufferTarget::Texture:
return kMGPipeBindSampler;
case BufferTarget::TransformFeedback:
return kMGPipeBindStreamOutput;
case BufferTarget::AtomicCounter:
return kMGPipeBindAtomic;
// TRANSFER AND QUERY TARGETS, which the bind mask deliberately does not name: none
// of them is a pipeline binding, none of them makes the server keep anything, and
// a bit set for them would only widen what a split server mirrors. Listed rather
// than defaulted, so the completeness assert still sees them.
case BufferTarget::CopyRead:
case BufferTarget::CopyWrite:
case BufferTarget::PixelPack:
case BufferTarget::PixelUnpack:
case BufferTarget::Query:
return kMGPipeBindNone;
case BufferTarget::BufferTargetCount:
case BufferTarget::Unknown:
return kMGPipeBindNone;
}
return kMGPipeBindUnmapped;
}
constexpr Bool MGPipeEveryBufferTargetIsMapped() {
for (SizeT i = 0; i < static_cast<SizeT>(BufferTarget::BufferTargetCount); ++i) {
if (MGPipeBindMaskForBufferTarget(static_cast<BufferTarget>(i)) == kMGPipeBindUnmapped) {
return false;
}
}
return true;
}
static_assert(MGPipeEveryBufferTargetIsMapped(),
"a BufferTarget enumerator has no MGPResourceDesc::BindMask row: add it to "
"MGPipeBindMaskForBufferTarget, including a deliberate kMGPipeBindNone, or the "
"resource it is bound to stops publishing that binding (D-A3, P8 expectation 1)");
static_assert(MGPipeBindMaskForBufferTarget(BufferTarget::Index) & kMGPipeBindElementArray,
"the ELEMENT_ARRAY bit is the index host mirror's switch (ARCHITECTURE.md 10.3)");
// ---------------------------------------------------------------------------------
// The discriminators MGPResourceDesc / MGPSubData carry for a BUFFER
// ---------------------------------------------------------------------------------
//
// MGPipeTypes.h documents Target as "Buffer | Tex1D..TexCubeArray | Renderbuffer |
// TexBuffer" and StorageKind as "== TextureStorageType", but P3a is buffer-only and the
// contract package minted no enum for the first list. Buffer is its leading member and
// is therefore 0, which is also what a zero-initialised record already says; the second
// is the frontend enum, named rather than open-coded.
inline constexpr Uint16 kMGPipeResourceTargetBuffer = 0;
inline constexpr Uint8 kMGPipeResourceStorageKindBuffer =
static_cast<Uint8>(MobileGL::TextureStorageType::Buffer);
// ---------------------------------------------------------------------------------
// D-A2: the payload builders. Pure, so a unit case can assert field by field.
// ---------------------------------------------------------------------------------
// The descriptor for `buffer`. `storageDefined` is false for the create that the
// constructor emits - storage is defined lazily by the first respecify and a backend
// tolerates a resource that has none - and true for every respecify.
inline MGPResourceDesc MGPipeBuildResourceDesc(const MG_State::GLState::BufferObject& buffer,
MGPipeHandle handle, Uint16 bindMask,
Bool storageDefined) {
MGPResourceDesc desc{};
desc.Resource = handle;
desc.Target = static_cast<Uint8>(kMGPipeResourceTargetBuffer);
desc.StorageKind = kMGPipeResourceStorageKindBuffer;
desc.BindMask = bindMask;
if (storageDefined) {
// MGPResourceDesc::Width is a Uint32 and that is the CONTRACT's shape, not this
// package's, so a store of 4 GiB or more cannot be declared at all. Truncating it
// silently is the one answer that must not happen: the applier's range gate would
// then refuse the first legal write past the truncated extent as
// Fatal{ProtocolCorruption} and name a corruption that is really a narrowing here.
// So it is said out loud, once, in every build - the assertion compiles out at
// INFO, which is what all three gate builds are.
if (buffer.GetSize() > static_cast<SizeT>(0xFFFFFFFFull)) {
MGLOG_E_ONCE("MGPipe: buffer %u declares a store of %llu bytes, which does not fit "
"MGPResourceDesc::Width - the descriptor's extent is narrowed and every "
"write past 4 GiB will be refused by the applier's range gate",
buffer.GetExternalIndex(),
static_cast<unsigned long long>(buffer.GetSize()));
MOBILEGL_ASSERT(false, "MGPResourceDesc::Width cannot carry this buffer's size");
}
desc.Width = static_cast<Uint32>(buffer.GetSize());
desc.Usage = static_cast<Uint32>(buffer.GetUsage());
desc.StorageFlags = static_cast<Uint32>(buffer.GetStorageFlags());
desc.Immutable = buffer.IsImmutableStorage() ? 1 : 0;
desc.HasDefinedContent = buffer.HasDefinedContent() ? 1 : 0;
}
// Diagnostics only: a GL name is never an identity, never a memo key and never part
// of a content hash (ARCHITECTURE.md 4.2.1).
desc.GlNameForDiag = static_cast<Uint32>(buffer.GetExternalIndex());
return desc;
}
// The buffer half of MGPSubData: the destination range rides in the box's first
// coordinate and first extent, and MGPipeSetSubDataBufferRange is the ONLY spelling of
// that convention. Returns false, with the record untouched, when the range does not fit
// one record - which is where MGPipeForEachSubDataRecordRange comes in.
//
// `sourceIsVerbatimLevelShadow` is the record's own question - "are these bytes an
// untransformed level shadow?" - and it is a PARAMETER because the answer differs by
// caller: resource_subdata hands over the client's own shadow at an offset into it and
// says yes; buffer_subdata_resident hands over the application's staging store, or the
// locally expanded pattern FillSubData built, and both say no. Nothing reads it on the
// buffer path today, which is exactly why it must not be a hard-coded 1 that becomes
// wrong the moment something does.
//
// Blob is FILLED, exactly: Seg is kMGHostSpanSegNone (monolith - the bytes travel beside
// the record through the entry point's companion pointer) and Size is the piece's own
// byte length, which is what the applier's ONE Blob rule holds a non-zero declaration to
// (PipeApply.cpp's SubDataBoxFault: != 0 && != MGPipeSubDataBufferSize is refused).
// Leaving it 0 would be legal too; declaring it correctly is the stronger of the two.
inline Bool MGPipeBuildSubDataRecord(MGPipeHandle res, Uint64 offset, Uint64 size, MGPSubData& out,
Bool sourceIsVerbatimLevelShadow) {
out = MGPSubData{};
out.Res = res;
out.Target = kMGPipeResourceTargetBuffer;
out.SourceIsVerbatimLevelShadow = sourceIsVerbatimLevelShadow ? 1 : 0;
if (!MGPipeSetSubDataBufferRange(out, offset, size)) return false;
out.Blob.Seg = kMGHostSpanSegNone;
out.Blob.Size = size;
return true;
}
// ONE record's destination box caps the offset at 2^31-1 and the size at 2^32-1
// (MGPipeTypes.h), so a range beyond either has to be split. The pieces are CONTIGUOUS
// and in ASCENDING order, and both properties are load-bearing rather than tidy:
// splitting a content write into overlapping or reordered pieces would change what the
// backend's queue-and-drain sees, and the Mali WAR-stall fix depends on that queue being
// exactly the writes the application made.
inline constexpr Uint64 kMGPipeSubDataMaxRecordOffset = 0x7FFFFFFFull;
inline constexpr Uint64 kMGPipeSubDataMaxRecordSize = 0xFFFFFFFFull;
// WITH THE RECORD'S OWN BOUND THE SPLIT IS NOT REACHABLE, and saying so is better than a
// loop that reads as if it were: a second piece starts at least 2^32-1 bytes past the
// first, which is already past the OFFSET cap, so a range too big for one record is
// REFUSED rather than split. The offset cap cannot be split away at all - every piece of
// a range that starts past 2^31-1 starts past it too - and a silent truncation is the one
// answer that must not happen, so the walk emits nothing and its caller says so once.
//
// `maxChunk` exists because the record's bound is not the tight one for long: a transport
// segment is far smaller (tens of MiB), and that is where this walk starts producing real
// splits. It is a parameter now, and exercised at a reachable value by the unit gate, so
// that lowering it is one argument rather than a new code path written under pressure.
template <class Fn>
inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece,
Uint64 maxChunk = kMGPipeSubDataMaxRecordSize) {
if (offset > kMGPipeSubDataMaxRecordOffset) return false;
if (size == 0) return true;
if (maxChunk == 0) return false;
// Every piece has to be encodable BEFORE any of them is emitted: a half-emitted range
// is a partial content write the backend would land as if it were the whole one.
const Uint64 chunkCap = maxChunk < kMGPipeSubDataMaxRecordSize ? maxChunk : kMGPipeSubDataMaxRecordSize;
for (Uint64 at = offset; at < offset + size; at += chunkCap) {
if (at > kMGPipeSubDataMaxRecordOffset) return false;
}
for (Uint64 at = offset, left = size; left > 0;) {
const Uint64 chunk = left > chunkCap ? chunkCap : left;
piece(at, chunk);
at += chunk;
left -= chunk;
}
return true;
}
// ---------------------------------------------------------------------------------
// The tracker: handles, the inverse, the sticky mask, the reverse channel
// ---------------------------------------------------------------------------------
class MGPipeResourceTracker {
public:
using BufferObject = MG_State::GLState::BufferObject;
using GLContext = MG_State::GLState::GLContext;
// The handle for `buffer`, minted on first use. Minting is NOT gated on a backend
// having registered MGPipeResourceOps: the handle is CLIENT state and
// set_vertex_buffers names it whether or not the resource family is switched on, so
// gating it would make the vertex-input subsystem emit null handles whenever the
// resource subsystem is off. Only the CALLS are gated (D-A1).
MGPipeHandle Acquire(BufferObject& buffer) {
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Buffer, buffer.GetLifetimeId());
const SizeT slot = handle.Slot;
if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1);
m_bySlot[slot].Object = &buffer;
m_bySlot[slot].Gen = handle.Gen;
return handle;
}
// The handle a buffer already has, or the null handle. Never mints - the emission
// path calls Acquire, the query paths call this.
MGPipeHandle Find(const BufferObject& buffer) const {
return MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, buffer.GetLifetimeId());
}
// D-D's inverse, and a RAW pointer is exact here: the entry exists only between the
// create the constructor emits and the destroy the destructor emits, and a readback
// is only ever issued for a live, bound buffer. A WeakPtr would be wrong - the
// object does not own itself through a SharedPtr at those two moments. The Gen
// compare is what refuses a stale handle rather than resolving it to whatever now
// occupies the slot.
BufferObject* Resolve(MGPipeHandle handle) const {
const SizeT slot = handle.Slot;
if (MGPipeHandleIsNull(handle) || slot >= m_bySlot.size()) return nullptr;
const Entry& entry = m_bySlot[slot];
if (entry.Object == nullptr || entry.Gen != handle.Gen) return nullptr;
if (MGPipeSlots().GenOfSlot(MGPipeKind::Buffer, handle.Slot) != handle.Gen) return nullptr;
return entry.Object;
}
// Drops the inverse entry and the sticky mask. The CALLER frees the slot afterwards,
// in that order (D-L): MGPipeSlotAllocator::Free erases the lifetimeId -> slot
// mapping, so anything that has to resolve the handle must do it first.
void Retire(MGPipeHandle handle) {
const SizeT slot = handle.Slot;
if (slot >= m_bySlot.size()) return;
m_bySlot[slot] = Entry{};
}
// ---- D-L: was resource_create actually EMITTED for this slot? ----
//
// The create is gated at its call site (BufferObject's constructor) and the destroy
// is gated inside MGPipeEmitResourceDestroyAndFree, so the two ask the SAME question
// at two different moments. A buffer constructed while a backend's table was
// registered and destroyed after UnregisterBufferBackendOps() would take the second
// answer, free its slot, and leave the applier's record Live - on a slot the
// allocator is about to hand out again, with the backend's twin (a driver buffer id)
// still attached to it. So the answer is LATCHED at the create and the destroy uses
// the latched one; the two are then a pair by construction rather than by the
// registration outliving every buffer.
void NotePublished(MGPipeHandle handle) {
const SizeT slot = handle.Slot;
if (slot >= m_bySlot.size()) return;
m_bySlot[slot].Published = true;
}
Bool WasPublished(MGPipeHandle handle) const {
const SizeT slot = handle.Slot;
return slot < m_bySlot.size() && m_bySlot[slot].Published;
}
// The sticky everBoundAs mask. Sticky exactly as MGPResourceDesc::ImageBindableHint's
// everImageBound is: ORed, never cleared, so a buffer that was an element array once
// keeps saying so.
Uint16 BindMask(MGPipeHandle handle) const {
const SizeT slot = handle.Slot;
return slot < m_bySlot.size() ? m_bySlot[slot].BindMask : Uint16{0};
}
// OR one target's bit into a handle's sticky mask, without looking at the context at
// all. This is what closes the sampling window for the two bits anything keys on:
// the vertex-input emitters resolve, at EVERY draw, exactly the attribute buffers and
// the element-slot buffer, so any buffer ever DRAWN FROM carries its ARRAY_BUFFER /
// ELEMENT_ARRAY bit for the rest of its life whether or not it happened to be bound
// at a storage op. It grows the table rather than dropping the note: it is called
// from the validate point, which is GL-thread by construction, and a slot outside the
// table is a buffer whose mint this process has not seen (a unit fixture's
// ResetForTest, in practice).
void NoteBoundAs(MGPipeHandle handle, BufferTarget target) {
if (MGPipeHandleIsNull(handle)) return;
const SizeT slot = handle.Slot;
if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1);
m_bySlot[slot].BindMask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(target));
}
// Accumulates into the sticky mask every target `buffer` is bound to RIGHT NOW, and
// returns the accumulated value.
//
// [DEVIATION, recorded in client-v2.md] D-A3 asks for the OR at every glBindBuffer /
// glBindBufferBase / glBindBufferRange / VAO element-slot bind, and C.1 points at
// MG_State/GLState/BufferState/BufferState.{h,cpp} for it - a file this package DOES
// own. The brief is wrong about where the entry points are: BufferState only VENDS
// BindingSlot<BufferObject>& / BindingSlotRange1D&, and the .Bind() calls are
// MG_Impl/GLImpl/Buffer/GL_Buffer.cpp's (BindBuffer_State, BindBufferBase_State,
// BindBufferRange_State), which C.5 assigns to no package. So the mask is accumulated
// by SAMPLING the frontend's live binding state instead - here, at every create and
// respecify, which is where the value is PUBLISHED - and ORed into a per-slot sticky
// field that is never cleared.
//
// WHAT SAMPLING ALONE CANNOT SEE is not "a bind after the last respecify" (which the
// specified design misses too) but a TRANSIENT bind: bind an EBO, draw, unbind, then
// define it through DSA - the respecify's sample sees no binding at all, and the DSA
// idiom makes that the common case rather than a corner (TryAdoptLargeStorage's own
// comment names glNamedBufferSubData as what MC 26.3 streams with). That hole is
// closed for the two bits anything keys on by NoteBoundAs above, called from
// EmitVertexBuffers / EmitIndexBuffer at every draw. What is left unpublished is a
// buffer that is bound, never drawn from, and never re-specified afterwards; the
// remaining fix is one line in each of GL_Buffer.cpp's three *_State binders, for the
// seven bits nothing keys on yet, and it stays handed to whoever owns that file.
//
// The scan is skipped unless a binding-slot version moved since the last one, which
// is one Uint16 load per global target and none per binding point. It is NOT called
// from the content emitters, deliberately: it walks the whole context's binding state
// and writes the tracker, and one of those emitters (resource_subdata) is on the path
// D-A2 preserves as reachable off the render thread. Extra sampling could only widen
// a sticky union, but not at the price of a context-wide read from the wrong thread.
Uint16 RefreshBindMask(GLContext& ctx, const BufferObject& buffer, MGPipeHandle handle) {
const SizeT slot = handle.Slot;
if (slot >= m_bySlot.size()) return 0;
Entry& entry = m_bySlot[slot];
const Uint64 epoch = BindEpoch(ctx);
if (epoch == m_bindEpoch && entry.BindMaskEpoch == epoch) return entry.BindMask;
m_bindEpoch = epoch;
entry.BindMaskEpoch = epoch;
Uint16 mask = entry.BindMask;
for (const auto target : MG_State::GLState::GlobalBufferTargets) {
if (ctx.GetBufferBindingSlot(target).GetBoundObject().get() == &buffer) {
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(target));
}
}
for (const auto target : MG_State::GLState::BufferBindPointTargets) {
const SizeT touched = ctx.GetTouchedBufferBindingPointCount(target);
for (SizeT i = 0; i < touched; ++i) {
if (ctx.GetBufferBindingPoint(target, static_cast<Uint>(i)).GetBoundObject().get() == &buffer) {
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(target));
break;
}
}
}
// The index slot is the BOUND VAO's, not BufferState's, so it is not in
// GlobalBufferTargets and GetBufferBindingSlot(Index) asserts without a VAO.
if (const auto& vao = ctx.GetBoundVertexArray()) {
if (vao->GetIndexBufferBindingSlot().GetBoundObject().get() == &buffer) {
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(BufferTarget::Index));
}
for (int i = 0; i < MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; ++i) {
if (vao->GetAttribute(static_cast<Uint>(i)).Buffer.get() == &buffer) {
mask |= static_cast<Uint16>(MGPipeBindMaskForBufferTarget(BufferTarget::Vertex));
break;
}
}
}
entry.BindMask = mask;
return mask;
}
// ---- the two observables a unit case reads (see the header comment) ----
const MGPResourceDesc& LastDesc() const { return m_lastDesc; }
Uint64 CreateCount() const { return m_creates; }
Uint64 RespecifyCount() const { return m_respecifies; }
Uint64 DestroyCount() const { return m_destroys; }
Uint64 MapPersistentCount() const { return m_mapPersistents; }
void NoteDesc(const MGPResourceDesc& desc, Bool isCreate) {
m_lastDesc = desc;
if (isCreate) {
++m_creates;
} else {
++m_respecifies;
}
}
void NoteDestroy() { ++m_destroys; }
void NoteMapPersistent() { ++m_mapPersistents; }
// A unit fixture's per-case reset, and the library never calls it. THE RULE, stated
// rather than left as an absence, because "nothing resets this" is not a reason:
//
// A buffer handle and the applier record it names are SHARE-GROUP OBJECT STATE.
// A GL object lives in a share group, not in a context, so a make-current changes
// neither. The applier's MGPipeApplierReset() is a make-current and deliberately
// keeps its Resources / VertexElementsCsos (PipeApply.h says so beside them); the
// ONLY things that drop a record are the object's own death signal -
// resource_destroy, which ~BufferObject raises through
// MGPipeEmitResourceDestroyAndFree, and delete_vertex_elements - and
// MGPipeApplierReleaseObjectRecords(), which is the SERVED CONTEXT's teardown and
// is deliberately wired to nothing in the monolith (there is one applier behind
// every context, so calling it on one context's destruction would drop every other
// context's records).
//
// So this tracker needs no re-publication path on a fresh context and must not have
// one: re-emitting resource_create for a record the applier still holds would move
// its Serial for nothing. What the client owes instead is the destroy - which
// ~BufferObject already emits, in the fixed emit-then-free order (D-L) - and that is
// the whole of the client's side of the record lifecycle.
//
// The vertex-input emitter's latches are the OTHER half and are genuinely per
// context: MGPipeVertexInputEmitter::Reset() is called from the FreshlyPrimed arm
// because the applier's vertex-input WORKING state (the bound handle, the window, the
// fetch shift) IS cleared there. Its vertex-elements RECORDS are not, which is why
// the emitter's Reset drops the "already published" latches but no create is lost:
// the latch is what says "re-publish", and re-publishing an unchanged configuration
// is a bounded over-fire, not a dropped write.
void ResetForTest() {
m_bySlot.clear();
m_bindEpoch = 0;
m_lastDesc = MGPResourceDesc{};
m_creates = m_respecifies = m_destroys = m_mapPersistents = 0;
}
private:
struct Entry {
BufferObject* Object = nullptr;
Uint32 Gen = 0;
Uint16 BindMask = 0;
Bool Published = false;
Uint64 BindMaskEpoch = 0;
};
// "Has any buffer binding moved since the last scan": the sum of the binding-slot
// versions, which BindingSlot bumps only on a real change. A collision costs one
// skipped rescan of ONE buffer's mask, and the mask is re-scanned at the next
// emission whose epoch differs, so it can delay a bit by one storage op and never
// drop one - the same over-fire-is-free / under-fire-is-fatal direction every
// shutter in Tracker.h takes.
//
// IT DOES NOT SEE THE 84x4 INDEXED BINDING POINTS, and that is sound only because
// BindBufferBase_State / BindBufferRange_State also bind the GENERIC slot for the
// same target (GL_Buffer.cpp:1531 says why), so an indexed bind always moves one of
// the versions summed here. If that ever stops being true, the CONSTANT /
// SHADER_BUFFER / ATOMIC / STREAM_OUTPUT bits start being missed silently and the
// repair is to fold GetTouchedBufferBindingPointCount into the epoch.
static Uint64 BindEpoch(GLContext& ctx) {
Uint64 epoch = 1;
for (const auto target : MG_State::GLState::GlobalBufferTargets) {
epoch += ctx.GetBufferBindingSlot(target).GetVersion();
epoch *= 3;
}
if (const auto& vao = ctx.GetBoundVertexArray()) {
epoch += vao->GetIndexBufferBindingSlot().GetVersion();
epoch = MGPipeMixShutterValue(epoch, vao->GetLifetimeId());
epoch = MGPipeMixShutterValue(epoch, vao->GetConfigVersion());
}
return epoch;
}
// The same mix Tracker.h's composite shutters use. Spelled here rather than
// included so this header does not depend on the tracker.
static constexpr Uint64 MGPipeMixShutterValue(Uint64 accumulator, Uint64 value) {
accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2);
return accumulator;
}
Vector<Entry> m_bySlot;
Uint64 m_bindEpoch = 0;
MGPResourceDesc m_lastDesc{};
Uint64 m_creates = 0;
Uint64 m_respecifies = 0;
Uint64 m_destroys = 0;
Uint64 m_mapPersistents = 0;
};
// The monolith's one resource tracker, beside the state tracker, the CSO cache and the
// set-hash suppressor.
inline MGPipeResourceTracker& MGPipeResourceTrackerInstance() {
static MGPipeResourceTracker tracker;
return tracker;
}
// ---------------------------------------------------------------------------------
// D-D: the client's half of the reverse channel
// ---------------------------------------------------------------------------------
// The backend produced the bytes of a readback and hands them back through the channel.
// The client resolves the handle to its own object and writes the shadow; the epoch bump
// stays SERVER-side and happens AFTER this returns, never before (ARCHITECTURE.md 7.4:
// the reverse channel needs the same ordering guarantee as the forward one).
inline void MGPipeClientOnBufferWriteback(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes) {
auto* buffer = MGPipeResourceTrackerInstance().Resolve(res);
if (buffer == nullptr) {
MGLOG_E_ONCE("MGPipe: OnBufferWriteback for a handle {%u,%u} that resolves to no buffer",
res.Slot, res.Gen);
return;
}
if (bytes.Seg != kMGHostSpanSegNone) {
MGLOG_E_ONCE("MGPipe: OnBufferWriteback carried a transport segment (%u); P3a is monolith only",
bytes.Seg);
return;
}
// Monolith: Seg is kMGHostSpanSegNone and Offset IS the address of the backend's
// mapped bytes (MGPipeTypes.h says so in as many words). Under a transport the
// segment resolves first, and that is the phase's edit, not this one's.
buffer->WritebackFromBackend(
DataPtr{reinterpret_cast<void*>(static_cast<std::uintptr_t>(bytes.Offset)),
static_cast<SizeT>(bytes.Size)},
static_cast<SizeT>(offset));
}
// A draw or dispatch wrote these ranges. ARCHITECTURE.md 7.1 calls this a NARROWING
// channel - the client builds a conservative pending set at its own emission points and
// the callback only ever removes from it - so P3a's implementation marks exactly what
// the three Espryt MarkGpuWritten sites mark today and the observable behaviour is
// unchanged. The narrowing itself is P8/P9's.
inline void MGPipeClientOnGpuWritten(MGPipeHandle res, Uint rangeCount, const MGPRange* ranges) {
// THE SHAPE IS A CONTRACT POINT, not a formality: the announcement is ONE range
// covering kMGPipeWholeBuffer, deliberately not ZERO ranges, because zero will mean
// "a fully narrowed set - nothing is dirty" at P8/P9. Marking the whole buffer
// written for a zero-range announcement would be the narrowing channel run backwards,
// so the shape is asserted here rather than assumed.
MOBILEGL_ASSERT(rangeCount == 1 && ranges != nullptr,
"OnGpuWritten {slot=%u, gen=%u}: P3a announces exactly one whole-buffer range, "
"not %u",
res.Slot, res.Gen, static_cast<Uint>(rangeCount));
(void)ranges;
if (rangeCount == 0) return;
auto* buffer = MGPipeResourceTrackerInstance().Resolve(res);
if (buffer == nullptr) {
// Loud, like its sibling above: a backend announcing a write against a handle
// this client cannot resolve is a dropped MarkGpuWritten, and a dropped
// MarkGpuWritten is a stale shadow read back as if it were current.
MGLOG_E_ONCE("MGPipe: OnGpuWritten for a handle {%u,%u} that resolves to no buffer", res.Slot,
res.Gen);
return;
}
buffer->MarkGpuWritten();
}
// Installed once, and never over an entry a backend already claimed: these two are the
// CLIENT's implementations of a backend -> frontend callback, so the backend installs
// the rest of the table and these two answer for it.
inline void MGPipeInstallClientResourceCallbacks() {
if (gMGPipeCallbacks.OnBufferWriteback == nullptr) {
gMGPipeCallbacks.OnBufferWriteback = &MGPipeClientOnBufferWriteback;
}
if (gMGPipeCallbacks.OnGpuWritten == nullptr) {
gMGPipeCallbacks.OnGpuWritten = &MGPipeClientOnGpuWritten;
}
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+93
View File
@@ -0,0 +1,93 @@
// MobileGL - MobileGL/MG_Impl/Pipe/SetHashSuppressor.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// Coalescing rule 4 (ARCHITECTURE.md 5.4, P2 brief D11): every kVarTail set_* hashes the
// RESOLVED set on the client and does not emit when the hash has not moved.
//
// This is the carrier for the ~175 lines of debounce that move off the backends in P3b and
// P4b - Espryt's UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and
// Magma's equivalents all answer "is this set the same set as last time", and every one of
// them answers it against a shape the backend rediscovered. P2 lands the MECHANISM and ONE
// real consumer (SetVertexAttribDefaults) so the shape is pinned by a test rather than by a
// plan; the other six slots exist, are unit-tested, and are wired by the phase that moves
// the set they name. P3a wires the second, SetVertexBuffers.
//
// A WIRED SLOT PUTS A REQUIREMENT ON ITS HASH, and SetVertexBuffers is where that first
// bites: the hash has to cover EVERY input the record carries, not only the set. Its
// baseInstance is DRAW state and moves without the buffer set moving, so a hash over the
// entries alone would suppress a record whose one changed field is the fetch shift and the
// server would keep the previous one. MG_Impl/Pipe/VertexInputEmit.h's
// MGPipeVertexBufferSetContentHash mixes Start, Count and BaseInstance in for exactly that
// reason, and VertexInputEmit's base-instance pair is the test that says so.
//
// A hash of 0 is reserved for "never emitted", so the first emission always goes out; a
// computed 0 is remapped to 1, which costs one collision in 2^64 an extra emission and
// never a missed one.
//
// Header-only for the same ownership reason as Tracker.h and CsoCache.h: the root
// CMakeLists.txt that would name a new .cpp belongs to package A and is frozen behind the
// p2/contract tag.
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/MGPipe.h>
namespace MobileGL::MG_Pipe {
// One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list).
enum class MGPipeSuppressorSlot : Uint32 {
SetVertexBuffers = 0, // P3a - wired, and its hash includes BaseInstance
SetSamplerViews, // P3b
BindSamplerStates, // P3b
SetShaderImages, // P4b
SetShaderBuffers, // P4b
SetStreamOutputTargets, // P4b
SetVertexAttribDefaults, // P2 - the one consumer that is wired
Count,
};
inline constexpr SizeT kMGPipeSuppressorSlotCount = static_cast<SizeT>(MGPipeSuppressorSlot::Count);
class MGPipeSetHashSuppressor {
public:
// True when `contentHash` differs from what this slot last emitted, and LATCHES it.
// False means the resolved set has not moved and the call must not go out.
Bool ShouldEmit(MGPipeSuppressorSlot slot, Uint64 contentHash) {
const Uint64 latched = contentHash == 0 ? 1 : contentHash;
const SizeT index = static_cast<SizeT>(slot);
if (m_lastEmitted[index] == latched) return false;
m_lastEmitted[index] = latched;
return true;
}
// A context change or a server reset: what the server has is no longer what this
// slot last emitted, so the next resolved set must go out whatever it hashes to.
void Invalidate(MGPipeSuppressorSlot slot) { m_lastEmitted[static_cast<SizeT>(slot)] = 0; }
void InvalidateAll() {
for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) m_lastEmitted[i] = 0;
}
// 0 == "never emitted". Exposed for the unit test, which is what pins that the
// reserved value really is reserved.
Uint64 LastEmitted(MGPipeSuppressorSlot slot) const {
return m_lastEmitted[static_cast<SizeT>(slot)];
}
private:
Array<Uint64, kMGPipeSuppressorSlotCount> m_lastEmitted{};
};
// The monolith's one suppressor, beside the tracker and the CSO cache.
inline MGPipeSetHashSuppressor& MGPipeSetHashSuppressorInstance() {
static MGPipeSetHashSuppressor suppressor;
return suppressor;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+174
View File
@@ -0,0 +1,174 @@
// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// SlotAllocator.h. Compiled only under MOBILEGL_PIPE_PUSH.
#include <MG_Impl/Pipe/SlotAllocator.h>
namespace MobileGL::MG_Pipe {
namespace {
// The ShaderCso band the ordinary allocator must never enter: the top 1/16 of the
// ShaderCso slot space is reserved for PROGRAM PIPELINE COMPOSITES, which are minted
// client-side out of the stage programs bound to a pipeline object. Reserving a band
// rather than a flag keeps the composite resolver's lifetime bookkeeping out of here
// (MGPipeHandles.h, ARCHITECTURE.md 5.6.3).
Bool SlotIsAllocatable(MGPipeKind kind, Uint32 slot) {
if (slot < kMGPipeFirstAllocatableSlot) return false;
if (kind != MGPipeKind::ShaderCso) return true;
return slot < kMGPipeShaderCsoCompositeSlotBase;
}
} // namespace
MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) {
const SizeT index = static_cast<SizeT>(kind);
MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index);
return m_kinds[index < kKindCount ? index : 0];
}
const MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) const {
const SizeT index = static_cast<SizeT>(kind);
MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index);
return m_kinds[index < kKindCount ? index : 0];
}
MGPipeHandle MGPipeSlotAllocator::Allocate(MGPipeKind kind) {
KindState& state = StateOf(kind);
if (state.Slots.empty()) {
// Slot 0 exists so the vector is slot-indexed, and is never handed out.
state.Slots.resize(kMGPipeFirstAllocatableSlot);
}
Uint32 slot = 0;
Bool reused = false;
while (!state.FreeList.empty()) {
const Uint32 candidate = state.FreeList.back();
state.FreeList.pop_back();
if (!SlotIsAllocatable(kind, candidate)) continue;
slot = candidate;
reused = true;
break;
}
if (!reused) {
slot = static_cast<Uint32>(state.Slots.size());
MOBILEGL_ASSERT(SlotIsAllocatable(kind, slot),
"MGPipe slot space of kind %u is exhausted at slot %u",
static_cast<Uint32>(kind), slot);
if (!SlotIsAllocatable(kind, slot)) return kMGPipeNullHandle;
state.Slots.emplace_back();
}
SlotState& entry = state.Slots[slot];
if (entry.EverHandedOut) {
// The one place Gen may move. 2^32 recycles of ONE slot is ~50 days of continuous
// churn at one recycle per frame at 1000 fps, which is why the bound is asserted
// in a debug allocator rather than defended in release.
MOBILEGL_ASSERT(entry.Gen != ~Uint32{0},
"MGPipe handle generation wrapped on kind %u slot %u; {slot, gen} is "
"no longer unique",
static_cast<Uint32>(kind), slot);
++entry.Gen;
}
entry.EverHandedOut = true;
entry.Live = true;
entry.LifetimeId = 0;
++state.LiveCount;
return MGPipeHandle{slot, entry.Gen};
}
MGPipeHandle MGPipeSlotAllocator::AllocateFor(MGPipeKind kind, Uint64 lifetimeId) {
const MGPipeHandle handle = Allocate(kind);
if (MGPipeHandleIsNull(handle)) return handle;
KindState& state = StateOf(kind);
state.Slots[handle.Slot].LifetimeId = lifetimeId;
if (lifetimeId != 0) {
MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(),
"lifetime id %llu already owns a slot of kind %u",
static_cast<unsigned long long>(lifetimeId), static_cast<Uint32>(kind));
state.ByLifetimeId[lifetimeId] = handle.Slot;
}
return handle;
}
MGPipeHandle MGPipeSlotAllocator::FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const {
if (lifetimeId == 0) return kMGPipeNullHandle;
const KindState& state = StateOf(kind);
const auto it = state.ByLifetimeId.find(lifetimeId);
if (it == state.ByLifetimeId.end()) return kMGPipeNullHandle;
const Uint32 slot = it->second;
if (slot >= state.Slots.size() || !state.Slots[slot].Live) return kMGPipeNullHandle;
return MGPipeHandle{slot, state.Slots[slot].Gen};
}
MGPipeHandle MGPipeSlotAllocator::Acquire(MGPipeKind kind, Uint64 lifetimeId) {
const MGPipeHandle existing = FindByLifetimeId(kind, lifetimeId);
if (!MGPipeHandleIsNull(existing)) return existing;
return AllocateFor(kind, lifetimeId);
}
void MGPipeSlotAllocator::Free(MGPipeKind kind, MGPipeHandle handle) {
KindState& state = StateOf(kind);
if (handle.Slot >= state.Slots.size()) return;
SlotState& entry = state.Slots[handle.Slot];
// A stale handle must not free the slot its successor now owns - that is the whole
// reason the generation is in the key.
if (!entry.Live || entry.Gen != handle.Gen) return;
if (entry.LifetimeId != 0) {
const auto it = state.ByLifetimeId.find(entry.LifetimeId);
if (it != state.ByLifetimeId.end() && it->second == handle.Slot) {
state.ByLifetimeId.erase(it);
}
}
entry.Live = false;
entry.LifetimeId = 0;
--state.LiveCount;
state.FreeList.push_back(handle.Slot);
}
Bool MGPipeSlotAllocator::IsLive(MGPipeKind kind, MGPipeHandle handle) const {
const KindState& state = StateOf(kind);
if (handle.Slot >= state.Slots.size()) return false;
const SlotState& entry = state.Slots[handle.Slot];
return entry.Live && entry.Gen == handle.Gen;
}
Uint32 MGPipeSlotAllocator::GenOfSlot(MGPipeKind kind, Uint32 slot) const {
const KindState& state = StateOf(kind);
if (slot >= state.Slots.size()) return 0;
return state.Slots[slot].Gen;
}
Uint64 MGPipeSlotAllocator::LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const {
const KindState& state = StateOf(kind);
if (slot >= state.Slots.size()) return 0;
return state.Slots[slot].LifetimeId;
}
Uint32 MGPipeSlotAllocator::HighWater(MGPipeKind kind) const {
return static_cast<Uint32>(StateOf(kind).Slots.size());
}
Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; }
Uint32 MGPipeSlotAllocator::FreeCount(MGPipeKind kind) const {
return static_cast<Uint32>(StateOf(kind).FreeList.size());
}
void MGPipeSlotAllocator::Reset() {
for (KindState& state : m_kinds) {
state.Slots.clear();
state.FreeList.clear();
state.ByLifetimeId.clear();
state.LiveCount = 0;
}
}
MGPipeSlotAllocator& MGPipeSlots() {
static MGPipeSlotAllocator allocator;
return allocator;
}
} // namespace MobileGL::MG_Pipe
+100
View File
@@ -0,0 +1,100 @@
// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include <MG_Pipe/MGPipeHandles.h>
// The CLIENT's slot allocator: the thing that mints every MGPipeHandle in the system
// (ARCHITECTURE.md 4.2 - no create_* call in the catalogue returns a server-cast handle,
// which is what lets the whole catalogue be remoted with zero creation round trips).
//
// Per kind: a free list plus a high-water mark, so slots stay DENSE and the server's object
// table is an array rather than a hash map. It has nothing to do with MG_State's
// IndexGenerator - that container's LIFO GL-name reuse is the very problem {slot, gen}
// exists to close, and the whole point of the identity is that an ABA on the GL name, on
// the heap address or on the lifetime id cannot reproduce a handle.
//
// Gen increments ONLY when a slot is reused, never on a respecify: a glBufferData on a live
// buffer keeps the same {slot, gen}, because the object is the same object. Two generations
// exist in the design and they are strictly separate - this is the client's answer to "is
// this still the same GL object"; MGGen is the server's epoch for "did I recast my driver
// object", and no MGPipe call may require the client to know it.
//
// The lifetimeId -> slot map is what keeps a GL NAME out of every key (ARCHITECTURE.md 4.2):
// the frontend object's lifetime id is the client's own identity for it, so the backend key
// is the handle and the frontend key is the lifetime id, and neither is a recyclable name.
//
// Lives in MG_Impl (the client side, unrestricted) and is compiled only under
// MOBILEGL_PIPE_PUSH. It is in the P2 CONTRACT commit rather than in a Track H package
// because both Track H slices - Espryt 0b and Magma subsystem 4 - key off it.
namespace MobileGL::MG_Pipe {
class MGPipeSlotAllocator {
public:
static constexpr SizeT kKindCount = static_cast<SizeT>(MGPipeKind::KindCount);
// A fresh {slot, gen} of this kind, from the free list if one is waiting and from the
// high-water mark otherwise. Never returns slot 0 (reserved: null, and the default
// framebuffer for kind Framebuffer), and never returns a ShaderCso slot inside the
// composite band, which the program-pipeline resolver mints out of separately.
MGPipeHandle Allocate(MGPipeKind kind);
// Allocate and remember `lifetimeId` as this handle's frontend identity.
MGPipeHandle AllocateFor(MGPipeKind kind, Uint64 lifetimeId);
// The handle a lifetime id was allocated for, or kMGPipeNullHandle. A recycled heap
// address does NOT reproduce a mapping: MG_State hands out a fresh lifetime id per
// object, so the map key is unique for the life of the process.
MGPipeHandle FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const;
// FindByLifetimeId, then AllocateFor when it misses. The ordinary client path.
MGPipeHandle Acquire(MGPipeKind kind, Uint64 lifetimeId);
// Returns the slot to the free list. The Gen bump happens on the NEXT handout of that
// slot, not here, so a handle that is freed twice cannot skip a generation and the
// "gen moves only on reuse" contract holds for an object that is never reused.
void Free(MGPipeKind kind, MGPipeHandle handle);
Bool IsLive(MGPipeKind kind, MGPipeHandle handle) const;
// 0 for a slot that was never handed out; the generation of the LAST handout
// otherwise, live or not.
Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const;
Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const;
// One past the highest slot ever handed out of this kind, i.e. what a server-side
// slot-indexed table must be sized to.
Uint32 HighWater(MGPipeKind kind) const;
Uint32 LiveCount(MGPipeKind kind) const;
Uint32 FreeCount(MGPipeKind kind) const;
// Context teardown / server reset / a unit test's fixture.
void Reset();
private:
struct SlotState {
Uint32 Gen = 0;
Bool Live = false;
Bool EverHandedOut = false;
Uint64 LifetimeId = 0;
};
struct KindState {
// Indexed by slot; [0] is the reserved slot and is never live.
Vector<SlotState> Slots;
Vector<Uint32> FreeList;
UnorderedMap<Uint64, Uint32> ByLifetimeId;
Uint32 LiveCount = 0;
};
KindState& StateOf(MGPipeKind kind);
const KindState& StateOf(MGPipeKind kind) const;
Array<KindState, kKindCount> m_kinds{};
};
// The monolith's one client allocator. Under split there is one per client context.
MGPipeSlotAllocator& MGPipeSlots();
} // namespace MobileGL::MG_Pipe
+528
View File
@@ -0,0 +1,528 @@
// MobileGL - MobileGL/MG_Impl/Pipe/Tracker.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// The frontend state tracker (ARCHITECTURE.md 5.2, P2 brief D4).
//
// WHERE IT RUNS. Not above MGP_FILL and not in the GL setter: MGPipeValidateForVerb, the
// one statement MGP_FILL already expands to before every gBackendFunctionsTable.GL call
// (PipeFill.h). Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND), so a
// setter that pushed would push twice per batch for a state the batch may not even read;
// the validate point coalesces the whole bracket into the two draws that observe it
// (ARCHITECTURE.md 5.1).
//
// WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2,
// computed by comparing a shutter against what the tracker last pushed. P2 emitted for bits
// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and the
// rest are still computed, latched and counted so the per-bit fire rate is a measurement
// rather than a plan, with their fields going through the residual fill until P3b/P4a/P4b.
//
// WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit
// that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the
// dangerous direction precisely because the P1 verify comparator cannot see it for
// object-class state (it compares those by identity only). So each shutter below is
// deliberately coarser than the state it guards - five bits share one buffer aggregate,
// the framebuffer bit fires on any attachment write anywhere - and the narrowing is P3's
// work, paid for with the fire rates this file publishes.
//
// NO TIMER LIVES HERE. ROADMAP.md forbids committing hot-path instrumentation; the
// absolute ns/draw comes from DriverBench, which times whole frames from outside the
// library (P2 brief D17). The only counting is the per-bit fire tally, behind
// PipeStats::Enabled() like every other counting site in the tree.
//
// HEADER-ONLY, and that is an ownership decision rather than a design one: the P2 brief
// asks for Tracker.{h,cpp}, but the root CMakeLists.txt that would have to name a new .cpp
// belongs to package A and is frozen behind the p2/contract tag. Everything here is
// included by exactly one translation unit in the library (MG_Impl/Pipe/PipeFill.cpp) plus
// the unit tests, so inline costs nothing. Splitting it back out is one list(APPEND) line.
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeValueTypes.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <cstring>
namespace MobileGL::MG_Pipe {
// One bit per row of the ARCHITECTURE.md 5.2 table, hand-written rather than generated:
// the list is design, not derived data, and the generator has nothing to derive it from.
enum class MGPipeDirty : Uint32 {
// ---- value class: P2 emits for these five ----
NewRenderState = 0, // RenderState::m_version -> set_dynamic_state
NewPipelineState, // RenderState::m_pipelineStateVersion -> create/bind_render_state
NewPixelPack, // PixelStoreParameters (pack) -> set_pixel_pack_state
NewPatchState, // the patch trio, NaN legal -> set_patch_state
NewVertexAttribDefaults, // glVertexAttrib* defaults -> set_vertex_attrib_defaults
// ---- value class: NEW_VERTEX_ELEMENTS is emitted from P3a; the other three are
// still computed and counted, and are emitted from P3b/P4a on ----
NewVertexElements, // the bound VAO's attribute configuration -> create/bind_vertex_elements
NewShader, // the current program's link version
NewShaderBindings, // image units, block bindings, uniform write set
NewGlobalConstants, // the default-uniform-block image
// ---- object class. THE FIRST TWO ARE P3a's, not P3b/P4b's: the roadmap puts
// set_vertex_buffers and set_index_buffer in the same phase as the vertex-elements
// trio, and this comment said otherwise until the commit that wired them. The rest
// are still computed and counted only. ----
NewVertexBuffers, // -> set_vertex_buffers (P3a)
NewIndexBuffer, // -> set_index_buffer (P3a)
NewFramebuffer,
NewSamplerViews,
NewSamplers,
NewShaderImages,
NewConstBuffers,
NewShaderBuffers,
NewSoTargets,
Count,
};
inline constexpr SizeT kMGPipeDirtyCount = static_cast<SizeT>(MGPipeDirty::Count);
static_assert(kMGPipeDirtyCount <= 32, "the dirty mask is a Uint32");
inline constexpr Uint32 MGPipeDirtyBit(MGPipeDirty bit) {
return Uint32{1} << static_cast<Uint32>(bit);
}
// The five P2 emits for. Each phase's constant survives as the next phase's A/B control
// and as what a test compares the subsystem map against, so none of them is edited in
// place when a later phase takes more bits over.
inline constexpr Uint32 kMGPipeDirtyEmittedAtP2 =
MGPipeDirtyBit(MGPipeDirty::NewRenderState) | MGPipeDirtyBit(MGPipeDirty::NewPipelineState) |
MGPipeDirtyBit(MGPipeDirty::NewPixelPack) | MGPipeDirtyBit(MGPipeDirty::NewPatchState) |
MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults);
// The three P3a adds: the vertex-input family, all on one subsystem.
inline constexpr Uint32 kMGPipeDirtyEmittedAtP3a =
kMGPipeDirtyEmittedAtP2 | MGPipeDirtyBit(MGPipeDirty::NewVertexElements) |
MGPipeDirtyBit(MGPipeDirty::NewVertexBuffers) | MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer);
inline constexpr const char* kMGPipeDirtyNames[kMGPipeDirtyCount] = {
"NEW_RENDER_STATE",
"NEW_PIPELINE_STATE",
"NEW_PIXEL_PACK",
"NEW_PATCH_STATE",
"NEW_VERTEX_ATTRIB_DEFAULTS",
"NEW_VERTEX_ELEMENTS",
"NEW_SHADER",
"NEW_SHADER_BINDINGS",
"NEW_GLOBAL_CONSTANTS",
"NEW_VERTEX_BUFFERS",
"NEW_INDEX_BUFFER",
"NEW_FRAMEBUFFER",
"NEW_SAMPLER_VIEWS",
"NEW_SAMPLERS",
"NEW_SHADER_IMAGES",
"NEW_CONST_BUFFERS",
"NEW_SHADER_BUFFERS",
"NEW_SO_TARGETS",
};
// Which runtime MOBILEGL_PIPE_PUSH subsystem bit gates a dirty bit's emission. Zero for
// a bit P2 does not emit, which is what makes "the bitmask is a true per-subsystem A/B"
// literally true rather than approximately.
inline constexpr Uint64 MGPipeSubsystemForDirty(MGPipeDirty bit) {
switch (bit) {
case MGPipeDirty::NewRenderState:
case MGPipeDirty::NewPipelineState:
return kMGPipeSubsystemRenderState;
case MGPipeDirty::NewPixelPack:
return kMGPipeSubsystemPixelPack;
case MGPipeDirty::NewPatchState:
return kMGPipeSubsystemPatchState;
case MGPipeDirty::NewVertexAttribDefaults:
return kMGPipeSubsystemVertexAttribDefaults;
// P3a's three, all one subsystem: create/bind_vertex_elements, set_vertex_buffers
// and set_index_buffer are the vertex-input family and an operator switching it off
// has to get the whole family's legacy arm, not two thirds of it.
// PipeFill.cpp's SubsystemForEmitter carries the pairing static_asserts.
case MGPipeDirty::NewVertexElements:
case MGPipeDirty::NewVertexBuffers:
case MGPipeDirty::NewIndexBuffer:
return kMGPipeSubsystemVertexInput;
default:
// The remaining bits have no call of their own until P3b/P4a/P4b, so there is no
// subsystem to switch and the residual fill keeps supplying their fields.
return 0;
}
}
// A COMPOSITE shutter, for the bits whose "did anything move" is more than one counter.
// It is a hash, so two different states can in principle collide and cost a MISSED fire.
// The five bits P2 emits for are never composed - they are widened counters and byte
// compares, neither of which can collide.
//
// P3a's three ARE composed, so the risk is now real rather than academic, and it is
// accepted with its size stated: each mix takes a 64-bit input into a 64-bit
// accumulator, so two DIFFERENT vertex configurations collide with probability ~2^-64
// per pair, and the inputs are a monotone lifetime id, a monotone configuration version
// and a widened slot version - none of which an application can steer. The alternative,
// comparing the whole 32-attribute configuration byte for byte on every verb, is the
// per-draw cost the shutter exists to avoid. The narrowing that removes the composition
// for bit 10 - its own slot version plus the bound object's identity - is what this
// phase already did to the one shutter that was composed over an unrelated aggregate.
inline constexpr Uint64 MGPipeMixShutter(Uint64 accumulator, Uint64 value) {
accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2);
return accumulator;
}
// A Uint16 counter widened at the TRACKER boundary, never in MG_State
// (ARCHITECTURE.md 5.2: MG_State is not changed for this). A decrease is a wrap and adds
// 65536. A wrap is harmless locally - one extra re-push, never a missed one - which is
// exactly what TrackerTest.WrapAroundRePushesButNeverMisses pins.
//
// THE ONE CASE IT CANNOT SEE, stated because "never a missed push" is otherwise stronger
// than what is true: the wrap test is `now < m_last`, so a counter that advances by
// EXACTLY 65536 (or a multiple) between two walks reads as unchanged. That needs 65536
// render-state mutations inside one verb boundary, and it is pre-existing in class -
// both backends already compare raw Uint16 versions the same way - so P2 records it
// rather than widening MG_State's counters, which ARCHITECTURE.md 5.2 rules out.
class MGPipeWidenedCounter {
public:
Uint64 Observe(Uint16 now) {
if (m_started && now < m_last) m_high += 0x10000ull;
m_started = true;
m_last = now;
return m_high + now;
}
void Reset() {
m_high = 0;
m_last = 0;
m_started = false;
}
private:
Uint64 m_high = 0;
Uint16 m_last = 0;
Bool m_started = false;
};
class MGPipeTracker {
public:
using GLContext = MG_State::GLState::GLContext;
// The dirty walk. Compares every shutter against what was last pushed, LATCHES the
// new values, counts the fires per verb class, and returns the mask. Latching here
// rather than after emission is deliberate: a bit whose subsystem is switched off is
// not emitted, but its fields are then still pulled by the residual fill, so the
// pushed block is correct either way and a bit can never fire twice for one change.
Uint32 Update(GLContext& ctx, MGPipeVerbClass verbClass) {
// A different context is a different server: nothing the tracker latched about
// the old one says anything about this one, and the first walk on a fresh
// context must publish a COMPLETE state rather than an increment.
if (m_context != &ctx) {
Reset();
m_context = &ctx;
}
const Bool wasPrimed = m_primed;
Uint64 now[kMGPipeDirtyCount];
const RenderStateParameters& render = ctx.GetRenderStateParameters();
// ---- bits 0..1: the two Uint16 render-state counters, widened HERE ----
now[Index(MGPipeDirty::NewRenderState)] =
m_renderStateVersion.Observe(static_cast<Uint16>(ctx.GetRenderStateParametersVersion()));
now[Index(MGPipeDirty::NewPipelineState)] =
m_pipelineStateVersion.Observe(static_cast<Uint16>(ctx.GetPipelineStateVersion()));
// ---- bit 4 and the value-class bits 5..8 ----
now[Index(MGPipeDirty::NewVertexAttribDefaults)] = ctx.GetAnyVertexAttribDefaultGeneration();
const auto& vao = ctx.GetBoundVertexArray();
const Uint64 vaoIdentity =
vao ? MGPipeMixShutter(vao->GetLifetimeId(), vao->GetConfigVersion()) : 0;
now[Index(MGPipeDirty::NewVertexElements)] = vaoIdentity;
// Deliberately NOT GetProgramForDraw: that joins a pending link, and the tracker
// must not force a compile just to answer "did the shader move". These version
// counters are plain members and are exactly what the backends already read
// without joining (Core.cpp, the glUseProgram half of join site J1).
const auto& program = ctx.GetCurrentProgram();
Uint64 shader = 0;
Uint64 bindings = 0;
Uint64 constants = 0;
Uint64 programImages = 0;
if (program) {
shader = MGPipeMixShutter(program->GetLifetimeId(), program->GetLinkVersion());
bindings = MGPipeMixShutter(
MGPipeMixShutter(MGPipeMixShutter(program->GetImageUnitVersion(),
program->GetBackendStateVersion()),
program->GetBlockBindingVersion()),
program->GetUniformWriteSetVersion());
constants = MGPipeMixShutter(program->GetLifetimeId(), program->GetUBOContentVersion());
programImages = program->GetImageUnitVersion();
}
now[Index(MGPipeDirty::NewShader)] = shader;
now[Index(MGPipeDirty::NewShaderBindings)] = bindings;
now[Index(MGPipeDirty::NewGlobalConstants)] = constants;
// ---- the object-class bits 9..17 ----
const Uint64 textureContent = ctx.GetAnyTextureContentGeneration();
const Uint64 textureParams = ctx.GetAnyTextureParamsGeneration();
const Uint64 buffers = ctx.GetAnyBufferChangeGeneration();
// Bit 9. The VAO attribute aggregate mixed with the bound VAO's identity is
// already exact for the SET - it is bumped by all three Bump*Version functions,
// which are the only writers of an attribute's format, buffer or enable state -
// and a driver-id re-mint that moves no client counter is caught server-side by
// the backend's own id generation.
//
// THE PENDING BASE INSTANCE IS MIXED IN, and this is a deviation from the design
// note that said "keep the shutter" (recorded in client-v1.md): the draw's
// baseInstance is now an EXPLICIT field of set_vertex_buffers and a
// ContentHash input, and it moves neither the attribute aggregate nor the VAO
// identity. Without it here, a draw whose only change is its base instance would
// never reach the emitter at all and the server would keep the previous fetch
// shift - which is the same silently-wrong-geometry the backend's
// baseInstanceDirty flag exists to prevent, one level further out. It fires
// extra only on the draws that actually carry one.
now[Index(MGPipeDirty::NewVertexBuffers)] = MGPipeMixShutter(
MGPipeMixShutter(ctx.GetAnyVaoAttributeGeneration(), vaoIdentity), m_pendingBaseInstance);
// Bit 10, NARROWED (P3a, D-I). It used to mix the whole buffer-CONTENT aggregate
// with the VAO identity and therefore fired on any buffer write anywhere; what
// it guards is one binding slot, so it now reads that slot's own version and the
// identity of what is bound to it. The version is a WRAPPING Uint16 bumped only
// on a real change, so it goes through the widened counter at this boundary; the
// bound object's lifetime id joins it because identity is what closes the wrap
// hole. The VAO identity stays in the mix because the element slot BELONGS to
// the bound VAO - switching VAOs switches slots.
Uint64 indexShutter = 0;
if (vao) {
const auto& indexSlot = vao->GetIndexBufferBindingSlot();
const auto& indexObject = indexSlot.GetBoundObject();
indexShutter = MGPipeMixShutter(m_indexSlotVersion.Observe(indexSlot.GetVersion()),
indexObject ? indexObject->GetLifetimeId() : 0);
}
now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(vaoIdentity, indexShutter);
now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter(
ctx.GetAnyFramebufferAttachmentGeneration(),
m_framebufferBind.Observe(
ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion()));
now[Index(MGPipeDirty::NewSamplerViews)] =
MGPipeMixShutter(textureContent, ctx.GetTextureBindGeneration());
now[Index(MGPipeDirty::NewSamplers)] =
MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration());
now[Index(MGPipeDirty::NewShaderImages)] =
MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages);
now[Index(MGPipeDirty::NewConstBuffers)] = buffers;
now[Index(MGPipeDirty::NewShaderBuffers)] = buffers;
now[Index(MGPipeDirty::NewSoTargets)] =
MGPipeMixShutter(buffers, ctx.GetTransformFeedbackGeneration());
Uint32 dirty = 0;
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
// Bits 2 and 3 are handled below: they are BitwiseEqual shutters, not
// counters, so they have no entry in `now`.
if (i == Index(MGPipeDirty::NewPixelPack) || i == Index(MGPipeDirty::NewPatchState)) {
continue;
}
if (!m_primed || now[i] != m_lastPushed[i]) dirty |= Uint32{1} << static_cast<Uint32>(i);
m_lastPushed[i] = now[i];
}
// ---- bit 2: the PACK half of the pixel store, BitwiseEqual ----
const PixelStoreParameters pack = ctx.GetPixelStoreParameters(false);
if (!m_primed || std::memcmp(&pack, &m_pack, sizeof(pack)) != 0) {
dirty |= MGPipeDirtyBit(MGPipeDirty::NewPixelPack);
m_pack = pack;
}
// ---- bit 3: the patch trio, BitwiseEqual, and NaN IS LEGAL ----
// A NaN outer level is a legal glPatchParameterfv value and must compare equal to
// itself (ARCHITECTURE.md 5.2). Float equality says it is not; memcmp says it is,
// which is the whole reason this is a byte compare.
PatchTrio patch{};
patch.PatchVertices = render.PatchVertices;
for (SizeT i = 0; i < 4; ++i) patch.Outer[i] = render.PatchDefaultOuterLevel[i];
for (SizeT i = 0; i < 2; ++i) patch.Inner[i] = render.PatchDefaultInnerLevel[i];
if (!m_primed || std::memcmp(&patch, &m_patch, sizeof(patch)) != 0) {
dirty |= MGPipeDirtyBit(MGPipeDirty::NewPatchState);
m_patch = patch;
}
m_primed = true;
m_freshlyPrimed = !wasPrimed;
m_lastDirty = dirty;
if (MG_Util::PipeStats::Enabled()) {
const SizeT cls = static_cast<SizeT>(verbClass);
++m_walks[cls];
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
if (dirty & (Uint32{1} << static_cast<Uint32>(i))) ++m_fires[i][cls];
}
}
return dirty;
}
// Context teardown, server reset, a unit test's fixture. The next Update returns
// every bit set, which is what makes the first verb on a fresh context publish a
// complete state rather than an increment. Deliberately does NOT clear the fire
// tallies: they are a per-run measurement, not per-context state.
//
// AND IT DELIBERATELY DOES NOT CLEAR m_pendingBaseInstance. Everything else this
// function clears is a LATCH describing what the server was last told; the pending
// base instance is THIS CALL'S ARGUMENT, written by the draw entry point one
// statement before MGP_FILL and not yet read by anybody. Update() calls Reset() from
// inside itself whenever the current GLContext pointer moves, so clearing it here
// meant that `eglMakeCurrent(ctxB); glDrawArraysInstancedBaseInstance(..., 7)` put a
// BaseInstance of 0 on the wire - one silently mis-shifted instanced draw per context
// switch, on the emulation path, with nothing to catch it. The value is cleared by the
// verb that consumes it (PipeFill.cpp's step 3, and its no-context early return) and
// by MGPipeLeaveVerb, which is where a per-call argument belongs.
void Reset() {
std::memset(m_lastPushed, 0, sizeof(m_lastPushed));
m_renderStateVersion.Reset();
m_pipelineStateVersion.Reset();
m_framebufferBind.Reset();
m_indexSlotVersion.Reset();
m_pack = PixelStoreParameters{};
m_patch = PatchTrio{};
m_staged = RenderStateParameters{};
m_stagedAttribs = AttribDefaults{};
m_context = nullptr;
m_lastDirty = 0;
m_primed = false;
m_freshlyPrimed = false;
}
void ResetCounters() {
std::memset(m_fires, 0, sizeof(m_fires));
std::memset(m_walks, 0, sizeof(m_walks));
}
Uint64 FireCount(MGPipeDirty bit, MGPipeVerbClass verbClass) const {
return m_fires[Index(bit)][static_cast<SizeT>(verbClass)];
}
Uint64 FireCount(MGPipeDirty bit) const {
Uint64 total = 0;
for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_fires[Index(bit)][i];
return total;
}
Uint64 WalkCount(MGPipeVerbClass verbClass) const {
return m_walks[static_cast<SizeT>(verbClass)];
}
Uint64 WalkCount() const {
Uint64 total = 0;
for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_walks[i];
return total;
}
Uint32 LastDirty() const { return m_lastDirty; }
Bool Primed() const { return m_primed; }
// True when the LAST Update was the first one after a Reset - a fresh context, or a
// server reset. The emission step reads it to send a COMPLETE state rather than an
// increment against a staging mirror that describes a context that is gone.
Bool FreshlyPrimed() const { return m_freshlyPrimed; }
// "What the server has" (P2 brief D8). set_dynamic_state sends the dynamic chunks
// that differ from this, which is the chunk-level suppressor; a chunk that
// memcmp-matches is not sent at all.
RenderStateParameters& Staged() { return m_staged; }
const RenderStateParameters& Staged() const { return m_staged; }
// The same mirror for the 32 glVertexAttrib* defaults: set_vertex_attrib_defaults
// names only the attributes that differ from it, which is the var-tail's own
// suppressor underneath D11's set-hash one.
using AttribDefaults = Array<MG_State::GLState::CurrentVertexAttributeValue,
MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS>;
AttribDefaults& StagedAttribDefaults() { return m_stagedAttribs; }
const AttribDefaults& StagedAttribDefaults() const { return m_stagedAttribs; }
// ---- P3a D-H2: the draw's vertex-FETCH base instance ----
//
// It lives HERE rather than in a file static because bit 9's shutter has to see it:
// an ambient process global cannot cross a pushed boundary, and the value is now an
// explicit field of set_vertex_buffers and an input to its content hash, so a draw
// whose only change is its base instance has to reach the emitter. Set immediately
// before the fill at the three *BaseInstance draw entry points; CONSUMED and cleared
// by the validate point once it has been emitted, so a plain draw that follows one
// sees 0 again.
//
// THE CLEAR THAT ACTUALLY RUNS IN PRODUCTION IS THE VALIDATE POINT'S. MGPipeLeaveVerb
// clears it too, but no GL entry point calls MGPipeLeaveVerb - only MG_Test's
// ScopedPipeVerb and TrackerTest do - so the production guarantee is entirely
// PipeFill.cpp's, on BOTH of its exits: the end of step 3, and the no-live-context
// early return that skips step 3 altogether. Reset() deliberately does not clear it
// (see there): it is this call's argument, not a latch.
void SetPendingBaseInstance(Uint32 baseInstance) { m_pendingBaseInstance = baseInstance; }
Uint32 PendingBaseInstance() const { return m_pendingBaseInstance; }
void ClearPendingBaseInstance() { m_pendingBaseInstance = 0; }
private:
static constexpr SizeT Index(MGPipeDirty bit) { return static_cast<SizeT>(bit); }
struct PatchTrio {
Uint PatchVertices;
Float Outer[4];
Float Inner[2];
};
Uint64 m_lastPushed[kMGPipeDirtyCount]{};
MGPipeWidenedCounter m_renderStateVersion;
MGPipeWidenedCounter m_pipelineStateVersion;
// The draw framebuffer BINDING slot version, widened for the same reason: a Uint16
// that wrapped would let a composite shutter repeat and cost a missed fire.
MGPipeWidenedCounter m_framebufferBind;
// The BOUND VAO's element-array slot version, widened for the same reason. One
// counter over a slot that changes with the bound VAO: a stale high word can only
// ADD a fire, never drop one, and the VAO identity in the same mix is what makes a
// switch between two VAOs differ whatever their slot versions read.
MGPipeWidenedCounter m_indexSlotVersion;
Uint32 m_pendingBaseInstance = 0;
// Bits 2 and 3 are BitwiseEqual shutters, not counters.
PixelStoreParameters m_pack{};
PatchTrio m_patch{};
RenderStateParameters m_staged{};
AttribDefaults m_stagedAttribs{};
const void* m_context = nullptr;
Uint32 m_lastDirty = 0;
Bool m_primed = false;
Bool m_freshlyPrimed = false;
Uint64 m_fires[kMGPipeDirtyCount][kMGPipeVerbClassCount]{};
Uint64 m_walks[kMGPipeVerbClassCount]{};
};
// ONE attribute default, flattened onto the wire (P2 brief D10). A named function rather
// than four lines inside the emitter because this flattening is the whole correctness
// question of set_vertex_attrib_defaults: a CurrentVertexAttributeValue is one value in
// three views and GLContext converts NUMERICALLY between them, so four words alone are
// not the value - glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue and 0x3FC00000 in
// floatValue. MGPAttribValue::ValueClass is what makes the four words readable again, and
// TrackerAttribPayload pins that here instead of leaving it to the emitter's shape.
inline void MGPipeFillAttribValue(Uint32 location,
const MG_State::GLState::CurrentVertexAttributeValue& value,
Uint32 writtenClass, MGPAttribValue& out) {
out = MGPAttribValue{};
out.Location = location;
out.ValueClass = static_cast<Uint8>(writtenClass);
static_assert(sizeof(out.Data) == sizeof(value.floatValue), "MGPAttribValue::Data is four words");
switch (writtenClass) {
case MG_State::GLState::kVertexAttribValueClassInt:
std::memcpy(out.Data, value.intValue.data(), sizeof(out.Data));
break;
case MG_State::GLState::kVertexAttribValueClassUint:
std::memcpy(out.Data, value.uintValue.data(), sizeof(out.Data));
break;
default:
std::memcpy(out.Data, value.floatValue.data(), sizeof(out.Data));
break;
}
}
// The monolith's one tracker. Under split there is one per client context; the context
// identity check inside Update is what makes the single instance safe today.
inline MGPipeTracker& MGPipeTrackerInstance() {
static MGPipeTracker tracker;
return tracker;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+438
View File
@@ -0,0 +1,438 @@
// MobileGL - MobileGL/MG_Impl/Pipe/VertexInputEmit.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// The CLIENT side of P3a's vertex-input family (brief D-G, D-H, D-I): the bound VAO's
// format as create/bind_vertex_elements, its buffers as set_vertex_buffers with an explicit
// baseInstance, and its element binding as set_index_buffer.
//
// UNLIKE THE RESOURCE FAMILY, these three emit at the VALIDATE POINT, from
// MGPipeValidateForVerb's step 3 in the fixed order elements -> buffers -> index. That is
// the ordinary rule (ARCHITECTURE.md 5.1); the resource family is the one exception to it.
//
// THE CSO IS IDENTITY-ADDRESSED, NOT CONTENT-ADDRESSED (D-G1, a recorded deviation from
// ARCHITECTURE.md's 1024-entry content-addressed scheme). One handle per frontend
// VertexArrayObject, minted off its lifetime id, and create_vertex_elements is RE-ISSUED on
// the same handle whenever the configuration moves - legal, because MGPipeHandle::Gen
// increments only on slot reuse and never on a respecify. Espryt has no vertex-elements CSO
// to share: its twin owns one driver VAO name plus 64 scratch buffer ids, which two frontend
// VAOs cannot share, so content addressing would be strictly slower on the only backend this
// phase touches. P7 adds the hash-probe-memcmp layer above these same three calls when
// Magma's VertexInputStateFactory takes the CSO over.
//
// WHAT THE UNIT GATE READS. G6 is "the emitted blob + set + index record reproduce exactly
// what the backend's VAO twin reads from the frontend today, field by field, for all 32
// slots", and G7 is a scripted control that stops the conversion copying ONE field and
// expects the suite to go red NAMING it. So the conversion is a pure function per field
// (MGPipeBuildVertexAttribWire / MGPipeBuildVertexBindingPointWire) and the staging buffers
// the emitter builds into are readable afterwards - the emitter passes m_blob and m_entries
// straight to the applier, so "what was emitted" costs no copy at all.
//
// HEADER-ONLY, for the ownership reason Tracker.h states in full.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/ResourceTracker.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/Tracker.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <xxhash.h>
#include <cstring>
namespace MobileGL::MG_Pipe {
// ---------------------------------------------------------------------------------
// D-G2: the wire conversion, one pure function per view
// ---------------------------------------------------------------------------------
// EVERY FIELD OF VertexAttribute THE WIRE FORM CARRIES, and nothing else:
//
// Divisor is deliberately absent - it is resolved per binding point and travels in
// MGPVertexBuffer::Divisor, which is where the backend's glVertexAttribDivisor reads
// it. Carrying it twice would let a malformed record disagree with itself.
// LegacyStride / LegacyPointer are deliberately absent - they are the
// glGetVertexAttrib* query answers and nothing but the query path reads them, so
// they stay client-side.
// Buffer is deliberately absent - identity travels in set_vertex_buffers, which is
// what keeps this record stable while the buffers under it change.
// Stride is the RESOLVED distance and a surviving 0 is MEANINGFUL: a pointer call's 0
// was already resolved to the element size by the frontend, so a 0 here can only
// have come from the binding model, where it means every vertex reads the SAME
// element. Collapsing it back into the element size is what made
// KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer.
// IsLong travels SEPARATELY from Type == Float64: VertexAttribFormat(GL_DOUBLE) reads
// doubles and asks for them converted to float, VertexAttribLFormat keeps all 64
// bits, and the backend's fp64 narrowing and its Adreno disabled-attribute
// workaround both key on telling the two apart.
inline MGPVertexAttribWire MGPipeBuildVertexAttribWire(const MG_State::GLState::VertexAttribute& attrib,
Uint32 bindingIndex) {
// ASSERT RATHER THAN ASSUME, in both directions, because the three narrowing casts
// below cross a package boundary: VertexArrayObject is another package's file and its
// 32-slot bound is its invariant, not this one's, so a BindingIndex of 256 would wrap
// to 0 and silently point every attribute at binding 0, and a negative Stride (the
// frontend field is a signed int) would arrive as a ~4 GiB unsigned distance.
MOBILEGL_ASSERT(bindingIndex < 256u,
"MGPVertexAttribWire::BindingIndex is a Uint8 and cannot carry %u",
static_cast<Uint>(bindingIndex));
MOBILEGL_ASSERT(attrib.Size >= 0 && attrib.Size <= 255,
"MGPVertexAttribWire::Size is a Uint8 and cannot carry %d", attrib.Size);
MGPVertexAttribWire wire{};
wire.Offset = static_cast<Uint64>(attrib.Offset);
wire.Stride = static_cast<Int32>(attrib.Stride);
wire.Type = static_cast<Uint32>(attrib.Type);
wire.Size = static_cast<Uint8>(attrib.Size);
wire.Enabled = attrib.Enabled ? 1 : 0;
wire.Normalized = attrib.Normalized ? 1 : 0;
wire.IsInteger = attrib.IsInteger ? 1 : 0;
wire.IsLong = attrib.IsLong ? 1 : 0;
wire.IsBgra = attrib.IsBgra ? 1 : 0;
wire.BindingIndex = static_cast<Uint8>(bindingIndex);
return wire;
}
// The ARB_vertex_attrib_binding view. Its initial Stride is 16, not 0 (GL 4.6 core table
// 23.4), which is why the wire form keeps it signed and copies it verbatim.
inline MGPVertexBindingPointWire
MGPipeBuildVertexBindingPointWire(const MG_State::GLState::VertexBufferBindingPoint& point) {
MGPVertexBindingPointWire wire{};
wire.Offset = static_cast<Uint64>(point.Offset);
wire.Stride = static_cast<Int32>(point.Stride);
wire.Divisor = static_cast<Uint32>(point.Divisor);
return wire;
}
// ---------------------------------------------------------------------------------
// D-H2.3: the content hash, WITH BaseInstance in it
// ---------------------------------------------------------------------------------
//
// A HARD REQUIREMENT, not a nicety. set_vertex_buffers is suppressed on an unchanged
// hash (SetHashSuppressor.h's SetVertexBuffers slot), so a baseInstance that moved while
// the buffer set did not would be suppressed and the server would keep the previous
// fetch shift - exactly the bug the backend's baseInstanceDirty flag exists to prevent.
inline Uint64 MGPipeVertexBufferSetContentHash(const MGPVertexBuffer* entries, Uint32 start, Uint32 count,
Uint32 baseInstance) {
Uint64 hash = XXH64(entries, static_cast<SizeT>(count) * sizeof(MGPVertexBuffer), 0);
hash = MGPipeMixShutter(hash, start);
hash = MGPipeMixShutter(hash, count);
hash = MGPipeMixShutter(hash, baseInstance);
return hash;
}
// ---------------------------------------------------------------------------------
// The emitter
// ---------------------------------------------------------------------------------
class MGPipeVertexInputEmitter {
public:
using GLContext = MG_State::GLState::GLContext;
using VertexArrayObject = MG_State::GLState::VertexArrayObject;
static constexpr SizeT kAttribs = static_cast<SizeT>(VertexArrayObject::MAX_VERTEX_ATTRIBS);
static constexpr SizeT kBindings = static_cast<SizeT>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS);
static_assert(kAttribs <= kMGPipeMaxVertexAttribs && kBindings <= kMGPipeMaxVertexAttribs,
"both declared counts are bounded by kMGPipeMaxVertexAttribs");
// create/bind_vertex_elements. D-G3's three arms, verbatim:
//
// no VAO bound -> bind the null handle (legal, and it means
// exactly "no VAO bound")
// the bound VAO CHANGED -> (re)create if its configuration moved since
// this handle last published one, then bind
// the same VAO, configuration MOVED-> create on the SAME handle, and do NOT rebind
//
// The latch is PER HANDLE, in a slot-indexed table, so ping-ponging between two VAOs
// re-binds but never re-creates either. A Uint32 configuration version does not wrap
// in any realistic run and is compared directly; the tracker's widened counter is
// for the Uint16s and is not needed here.
Uint64 EmitVertexElements(GLContext& ctx) {
const auto& vao = ctx.GetBoundVertexArray();
if (!vao) {
if (!MGPipeHandleIsNull(m_boundHandle)) {
MGPipeApplyBindVertexElements(HandleOnly(kMGPipeNullHandle));
++m_binds;
m_boundHandle = kMGPipeNullHandle;
m_boundLifetimeId = 0;
}
return 0;
}
const Uint64 lifetimeId = vao->GetLifetimeId();
const Uint32 configVersion = vao->GetConfigVersion();
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::VertexElementsCso, lifetimeId);
const SizeT slot = handle.Slot;
if (slot >= m_latch.size()) m_latch.resize(slot + 1);
Latch& latch = m_latch[slot];
Uint64 bytes = 0;
const Bool configMoved = !latch.Published || latch.ConfigVersion != configVersion ||
latch.Gen != handle.Gen;
if (configMoved) bytes += EmitCreate(*vao, handle, latch, configVersion);
if (lifetimeId != m_boundLifetimeId || m_boundHandle != handle) {
MGPipeApplyBindVertexElements(HandleOnly(handle));
++m_binds;
bytes += sizeof(MGPHandleOnly);
m_boundHandle = handle;
m_boundLifetimeId = lifetimeId;
}
return bytes;
}
// set_vertex_buffers. Espryt consumes RESOLVED attributes, so the set is one entry
// per attribute slot with BindingIndex == the attribute index; Start is 0 and Count
// is the highest ENABLED attribute plus one, which is the 32-slot prefix walk the
// dirty bit is specified over.
//
// A client-memory array is Res == kMGPipeNullHandle, and that is not a hole: it is
// exactly how the server learns "this attribute is client-sourced, upload it
// yourself". Its store genuinely does not exist at this moment - the client-array
// uploader runs after PrepareForDraw, at the draw entry point - and moving that
// resolution to the client is P8's.
Uint64 EmitVertexBuffers(GLContext& ctx, Uint32 baseInstance) {
const auto& vao = ctx.GetBoundVertexArray();
Uint32 count = 0;
if (vao) {
for (SizeT i = 0; i < kAttribs; ++i) {
if (vao->GetAttribute(static_cast<Uint>(i)).Enabled) count = static_cast<Uint32>(i) + 1;
}
for (SizeT i = 0; i < count; ++i) {
const auto& attrib = vao->GetAttribute(static_cast<Uint>(i));
MGPVertexBuffer& entry = m_entries[i];
entry = MGPVertexBuffer{};
entry.Res = attrib.Buffer ? MGPipeSlots().Acquire(MGPipeKind::Buffer,
attrib.Buffer->GetLifetimeId())
: kMGPipeNullHandle;
// D-A3's sticky mask, ORed HERE rather than only sampled at a storage op.
// This is the bit that survives the DSA idiom: a buffer defined through
// glNamedBuffer* may never be bound at any resource emission, but a draw
// that fetches from it resolves it right here, on the GL thread, at every
// draw. Sticky, so one draw is enough for the rest of its life.
MGPipeResourceTrackerInstance().NoteBoundAs(entry.Res, BufferTarget::Vertex);
// The attribute's own byte offset lives in MGPVertexAttribWire::Offset,
// so the entry's is the BINDING's, which the frontend already folded in.
entry.Offset = 0;
// Signed on the frontend, unsigned on the wire, and a negative one would
// arrive as a ~4 GiB fetch distance rather than as an error.
MOBILEGL_ASSERT(attrib.Stride >= 0, "a resolved vertex stride is never negative (%d)",
attrib.Stride);
entry.Stride = static_cast<Uint32>(attrib.Stride);
entry.Divisor = static_cast<Uint32>(attrib.Divisor);
entry.BindingIndex = static_cast<Uint32>(i);
}
}
const Uint64 hash = MGPipeVertexBufferSetContentHash(m_entries.data(), 0, count, baseInstance);
if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, hash)) {
return 0;
}
m_lastBuffers = MGPVertexBuffers{};
m_lastBuffers.Start = 0;
m_lastBuffers.Count = count;
// THE DRAW'S RAW value. The client never pre-shifts an offset and never learns
// whether the server emulated the shift or let GL_EXT_base_instance do it -
// emulation is server-owned.
m_lastBuffers.BaseInstance = baseInstance;
m_lastBuffers.ContentHash = hash;
MGPipeApplySetVertexBuffers(m_lastBuffers, m_entries.data());
++m_bufferSets;
return sizeof(MGPVertexBuffers) + static_cast<Uint64>(count) * sizeof(MGPVertexBuffer);
}
// set_index_buffer. An INDEPENDENT call, not a subset of the vertex-elements
// configuration version (D5) - the index slot is explicitly outside the VAO's
// m_configVersion, and the shutter for it is bit 10's, narrowed in Tracker.h.
//
// Offset and IndexSize are 0 here and the draw verb overrides them: at the validate
// point there is no draw to read them from, and the applier stores what it is given.
Uint64 EmitIndexBuffer(GLContext& ctx) {
const auto& vao = ctx.GetBoundVertexArray();
m_lastIndex = MGPIndexBuffer{};
if (vao) {
if (const auto& bound = vao->GetIndexBufferBindingSlot().GetBoundObject()) {
m_lastIndex.Res = MGPipeSlots().Acquire(MGPipeKind::Buffer, bound->GetLifetimeId());
// The ELEMENT_ARRAY bit, and it is the one the split path keys on
// (kCapNeedsHostIndexBytes -> restart rewriting, multi-draw flattening).
// Noted at every draw for RefreshBindMask's reason: an EBO defined through
// DSA and unbound before its last respecify would otherwise never publish
// it, and getting that bit wrong is invisible in monolith.
MGPipeResourceTrackerInstance().NoteBoundAs(m_lastIndex.Res, BufferTarget::Index);
}
}
MGPipeApplySetIndexBuffer(m_lastIndex);
++m_indexSets;
return sizeof(MGPIndexBuffer);
}
// ---- what a unit case reads. None of it costs a copy: the emitter builds INTO
// these and hands the applier the same pointers. ----
const Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs>& LastAttributes() const { return m_attributes; }
const Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs>& LastBindingPoints() const {
return m_bindingPoints;
}
const MGPVertexElements& LastElements() const { return m_lastElements; }
const MGPVertexBuffers& LastVertexBuffers() const { return m_lastBuffers; }
const Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs>& LastEntries() const { return m_entries; }
const MGPIndexBuffer& LastIndexBuffer() const { return m_lastIndex; }
MGPipeHandle BoundHandle() const { return m_boundHandle; }
Uint64 CreateCount() const { return m_creates; }
Uint64 BindCount() const { return m_binds; }
Uint64 VertexBufferSetCount() const { return m_bufferSets; }
Uint64 IndexBufferSetCount() const { return m_indexSets; }
// ---- C-1: "does the applier hold a record for exactly this handle?" ----
//
// The CSO's death path (MGPipeEmitVertexElementsDestroyAndFree) needs that answer and
// MUST NOT GUESS IT FROM THE SLOT. A VertexElementsCso slot can exist with no record
// behind it, because a backend that keys its twins on the handle mints the slot itself
// (DirectGLES' BackendSlotTable::GetOrCreate -> MGPipeSlots().Acquire) whether or not
// bit 8 ever asked this client to emit anything - which is exactly what a
// MOBILEGL_PIPE_PUSH=0x7f lane runs. delete_vertex_elements on such a handle is a
// REFUSED call, and the applier's resolver asserts on a refusal
// (PipeApply.cpp's ResolveVertexElements), i.e. a stop in a verify build.
//
// Kept OUT of Reset(), unlike the create/bind latch beside it, and for the mirror
// image of Reset()'s own reason: "a fresh context is a fresh server" is true of the
// per-context half of this table, and object RECORDS are precisely what
// MGPipeApplierReset does not clear (PipeApply.h's two halves). This half tracks those
// records, so it lives exactly as long as they do.
Bool RecordIsPublished(MGPipeHandle handle) const {
if (MGPipeHandleIsNull(handle)) return false;
const SizeT slot = handle.Slot;
if (slot >= m_latch.size()) return false;
const Latch& latch = m_latch[slot];
return latch.RecordLive && latch.RecordGen == handle.Gen;
}
// The record named by `handle` is gone from the applier. Also drops the bound-handle
// memo when it named it, so the client's idea of BoundVertexElements and the applier's
// (which MGPipeApplyDeleteVertexElements just cleared for the same handle) stay in
// step rather than diverging until the next bind happens to correct it.
void NoteRecordDestroyed(MGPipeHandle handle) {
if (MGPipeHandleIsNull(handle)) return;
const SizeT slot = handle.Slot;
if (slot < m_latch.size() && m_latch[slot].RecordGen == handle.Gen) {
m_latch[slot] = Latch{};
}
if (m_boundHandle == handle) {
m_boundHandle = kMGPipeNullHandle;
m_boundLifetimeId = 0;
}
}
// A fresh context is a fresh server: the applier's records are gone, so every latch
// this emitter holds describes objects the server no longer has. Called from the
// validate point's FreshlyPrimed arm beside MGPipeApplierReset and the suppressor's
// InvalidateAll, for the same reason they are.
//
// The PER-CONTEXT half only - see RecordIsPublished above for why RecordLive/RecordGen
// survive. Re-creating a configuration the applier already holds is a bounded
// over-fire (MGPipeApplyCreateVertexElements starts the record over); forgetting that
// it holds one at all would leak the record and its slot at the object's death.
void Reset() {
for (Latch& latch : m_latch) {
latch.Published = false;
latch.Gen = 0;
latch.ConfigVersion = 0;
}
m_boundHandle = kMGPipeNullHandle;
m_boundLifetimeId = 0;
}
void ResetCounters() { m_creates = m_binds = m_bufferSets = m_indexSets = 0; }
private:
struct Latch {
// The PER-CONTEXT half: "has this emitter told THIS server about this handle's
// configuration". Cleared by Reset() at every make-current.
Bool Published = false;
Uint32 Gen = 0;
Uint32 ConfigVersion = 0;
// The RECORD half: "does the applier hold a create_vertex_elements record at this
// slot, for this generation". Lives as long as the record does - see
// RecordIsPublished.
Bool RecordLive = false;
Uint32 RecordGen = 0;
};
static MGPHandleOnly HandleOnly(MGPipeHandle handle) {
MGPHandleOnly only{};
only.Handle = handle;
only.Kind = static_cast<Uint32>(MGPipeKind::VertexElementsCso);
return only;
}
Uint64 EmitCreate(const VertexArrayObject& vao, MGPipeHandle handle, Latch& latch, Uint32 configVersion) {
// ALL 32 OF EACH, deliberately. The record DECLARES both counts and the applier
// refuses one whose counts do not describe its own blob, so a self-describing
// record is the cheap shape - and G6 is stated over all 32 slots, which a
// truncated set could not answer. It rides create_vertex_elements only, i.e.
// once per configuration change, never per draw.
for (SizeT i = 0; i < kAttribs; ++i) {
m_attributes[i] = MGPipeBuildVertexAttribWire(vao.GetAttribute(static_cast<Uint>(i)),
vao.GetAttributeBindingIndex(static_cast<Uint>(i)));
}
for (SizeT i = 0; i < kBindings; ++i) {
m_bindingPoints[i] = MGPipeBuildVertexBindingPointWire(vao.GetBindingPoint(static_cast<Uint>(i)));
}
// Attributes first, then binding points, both ascending and contiguous.
constexpr SizeT kAttribBytes = kAttribs * sizeof(MGPVertexAttribWire);
constexpr SizeT kBindingBytes = kBindings * sizeof(MGPVertexBindingPointWire);
std::memcpy(m_blob.data(), m_attributes.data(), kAttribBytes);
std::memcpy(m_blob.data() + kAttribBytes, m_bindingPoints.data(), kBindingBytes);
m_lastElements = MGPVertexElements{};
m_lastElements.Cso = handle;
m_lastElements.AttributeCount = static_cast<Uint32>(kAttribs);
m_lastElements.BindingPointCount = static_cast<Uint32>(kBindings);
m_lastElements.Blob.Seg = kMGHostSpanSegNone;
m_lastElements.Blob.Offset = 0;
m_lastElements.Blob.Size = kAttribBytes + kBindingBytes;
MGPipeApplyCreateVertexElements(m_lastElements, m_blob.data());
++m_creates;
latch.Published = true;
latch.Gen = handle.Gen;
latch.ConfigVersion = configVersion;
// THE ONE PRODUCER of the record half: a create that reached the applier is the
// only thing that makes delete_vertex_elements a legal call for this handle.
latch.RecordLive = true;
latch.RecordGen = handle.Gen;
return sizeof(MGPVertexElements) + kAttribBytes + kBindingBytes;
}
Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs> m_attributes{};
Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs> m_bindingPoints{};
Array<Uint8, kMGPipeMaxVertexAttribs *(sizeof(MGPVertexAttribWire) + sizeof(MGPVertexBindingPointWire))>
m_blob{};
Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs> m_entries{};
MGPVertexElements m_lastElements{};
MGPVertexBuffers m_lastBuffers{};
MGPIndexBuffer m_lastIndex{};
Vector<Latch> m_latch;
MGPipeHandle m_boundHandle = kMGPipeNullHandle;
Uint64 m_boundLifetimeId = 0;
Uint64 m_creates = 0;
Uint64 m_binds = 0;
Uint64 m_bufferSets = 0;
Uint64 m_indexSets = 0;
};
// The monolith's one vertex-input emitter, beside the tracker, the CSO cache, the
// set-hash suppressor and the resource tracker.
inline MGPipeVertexInputEmitter& MGPipeVertexInputEmitterInstance() {
static MGPipeVertexInputEmitter emitter;
return emitter;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+576
View File
@@ -52,6 +52,7 @@ add_executable(MobileGLIntegrationTest
Main.cpp
Harness/HeadlessGL.cpp
Harness/BackendCapsPeek.cpp
Harness/PipeSlotPeek.cpp
Scenarios/OrientationScenario.cpp
Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.cpp
@@ -129,6 +130,9 @@ add_executable(MobileGLIntegrationTest
Scenarios/DualSourceBlendScenario.cpp
Scenarios/PipeVerifyArmingScenario.cpp
Scenarios/PoisonOmissionScenario.cpp
Scenarios/HandleRecycleScenario.cpp
Scenarios/CsoContentAddressingScenario.cpp
Scenarios/ResourceSubsystemControlScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -318,6 +322,190 @@ function(mgl_itest_join_environment outVar)
set(${outVar} "${joined}" PARENT_SCOPE)
endfunction()
# --- what THIS TREE implements, answered by the build rather than by a person ----------
#
# Two P2 entries assert something that only EXISTS once another P2 package has landed:
# HandleRecycleScenario's Handles arm needs a backend keyed on {slot, gen} (packages C and D),
# its AbaControl arm needs a consumer for MOBILEGL_PIPE_HANDLE_ABA_CONTROL (package D), and
# CsoContentAddressingScenario needs the client-side tracker that mints CSOs at all (package B).
# The gates package is written and merged FIRST, against the P2 contract commit, precisely so
# that the AbaControl red is on the record before either backend is touched - so for a while
# those entries have nothing to assert.
#
# The honest report for that is a SKIP naming what is missing, never a deleted registration and
# never a green that means "the thing I test does not exist yet". What decides the skip is
# THIS block, so that nobody has to remember to remove a hand-written guard:
#
# * two of the three answers are pure EXISTENCE checks, through file(GLOB CONFIGURE_DEPENDS).
# Ninja re-evaluates such a glob before every build and reconfigures only when the RESULT
# changes, so these cost nothing until the file appears - and then they arm themselves.
# * the third has to read a file's CONTENTS, because package D re-keys inside an existing
# source rather than adding one. VertexInputStateFactory.cpp is the one file both of D's
# answers live in (ComputeHash's buffer key is what the re-key changes AND what the ABA
# knob reverts), it is small, and it is watched by name - so an edit to it reconfigures and
# an edit anywhere else in the backend does not.
#
# Every verdict is printed at configure time: a marker that silently answered "no" for a tree
# that does implement the thing would turn a real gate into a permanent skip.
set(MGL_ITEST_CAPABILITY_ENV "")
# Whether the library under test compiled the push arm. Passed in rather than inferred, because
# the two CSO counters and the cso[] bracket of the stats line are #if MOBILEGL_PIPE_PUSH: in a
# pull build there is no CSO to mint and no channel to read, so the control has nothing to say -
# and "nothing to say" must be a SKIP that names the reason, not an assertion failure about a
# missing bracket.
#
# The lanes themselves are registered in BOTH builds even so. `ctest -L integration-gpu` has to
# be name-for-name identical between the pull build and the push build (P2 gate G2), and a lane
# that exists in only one of them breaks that comparison for every future package - a much worse
# outcome than four entries that skip.
if (MOBILEGL_PIPE_PUSH)
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_PUSH_BUILD=1")
endif()
# THE THREE MARKERS BELOW ANSWER A QUESTION ABOUT THE SOURCE TREE, so each is only a true
# statement about THIS LIBRARY while this build compiles the arm the source implements - and all
# three arms are `#if MOBILEGL_PIPE_PUSH`. A pull build has no {slot, gen} key (the slot tables
# and the re-keyed memos are push-only) and no Features.PipeHandleAbaControl at all (Config.h
# declares the field inside `#if MOBILEGL_PIPE_PUSH` and ConfigLoader parses it in the same arm).
# A source-only probe would therefore arm the PULL build's lanes the moment packages C and D
# land: the AbaControl lane would go hard red on a gate G2 requires green (the guards it means to
# defeat are still in force, so the scenario's "expect the stale pixels" assertion fails), and the
# Handles lane would report green against a library that contains no re-key at all - the
# "test that cannot fail" this scenario exists to avoid.
#
# So the whole block sits under the same `if (MOBILEGL_PIPE_PUSH)` as MGITEST_PIPE_PUSH_BUILD, and
# HandleRecycleScenario re-checks that marker before either arm asserts, so a hand-forced
# environment cannot arm an arm this build does not have either.
#
# ALL FOUR MARKERS ARE CONTENT PROBES, AND NONE OF THEM NAMES A FILE. A probe for a filename asks
# the wrong question: the owning package chooses its own file layout, so the moment it moves the
# code the probe answers "no" forever and the arm skips with a reason that has become false - a
# test quietly measuring nothing, which is the one outcome this whole scenario exists to prevent.
# The CSO probe was rewritten for exactly that reason once already; the magma probe still read one
# hard-coded .cpp, and package D already keeps one of its two Features.PipeHandleAbaControl
# consumers in a different file of the same directory (Renderer/VulkanRenderer.cpp), so it was one
# refactor away from a permanent AbaControl skip. So all four now ask "does any source in the
# directory the owning package owns name this symbol?", which is the thing each arm actually needs.
#
# Staleness cannot creep in from either side: the GLOB is CONFIGURE_DEPENDS (a file added or
# removed re-runs it) and every file it finds is appended to CMAKE_CONFIGURE_DEPENDS (an edit to
# one re-runs it).
function(mgl_itest_probe_for_symbol outVar directory symbolRegex)
file(GLOB_RECURSE mglItestProbeSources CONFIGURE_DEPENDS
"${directory}/*.h" "${directory}/*.hpp" "${directory}/*.cpp" "${directory}/*.c")
set(mglItestProbeHit "")
foreach(mglItestProbeSource IN LISTS mglItestProbeSources)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mglItestProbeSource}")
file(STRINGS "${mglItestProbeSource}" mglItestProbeLines REGEX "${symbolRegex}")
if (mglItestProbeLines AND NOT mglItestProbeHit)
set(mglItestProbeHit "${mglItestProbeSource}")
endif()
endforeach()
set(${outVar} "${mglItestProbeHit}" PARENT_SCOPE)
endfunction()
if (MOBILEGL_PIPE_PUSH)
# DirectGLES' Track H arm, probed by the subsystem bit it is gated on rather than by
# SlotTables.h existing: the bit is declared in the contract (MG_Pipe/MGPipe.h:77) and the
# backend has to name it to honour MOBILEGL_PIPE_PUSH's default mask, whatever files package C
# spreads the slot tables across.
mgl_itest_probe_for_symbol(MGL_ITEST_ESPRYT_SLOTS
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES" "kMGPipeSubsystemEsprytSlots")
if (MGL_ITEST_ESPRYT_SLOTS)
message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (${MGL_ITEST_ESPRYT_SLOTS})")
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1")
else()
message(STATUS "Integration tests: no DirectGLES source names kMGPipeSubsystemEsprytSlots - "
"HandleRecycle.Handles will SKIP on it")
endif()
# The CSO counters' EMITTER. The tracker package may implement the tracker and the cache
# header-only - today it does (MG_Impl/Pipe/{Tracker,CsoCache}.h, no Tracker.cpp) - so what is
# looked for is what the control actually reads: a source emitting the two counters.
mgl_itest_probe_for_symbol(MGL_ITEST_CSO_EMITTER
"${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "RenderStateCso(Mints|Binds)")
if (MGL_ITEST_CSO_EMITTER)
message(STATUS "Integration tests: the CSO counters have an emitter (${MGL_ITEST_CSO_EMITTER})")
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1")
else()
message(STATUS "Integration tests: no MG_Impl/Pipe source emits RenderStateCsoMints/Binds - "
"CsoContentAddressing will SKIP")
endif()
# DirectVulkan's Track H arm, and the ABA knob's consumer. Both over the whole backend
# directory: the re-key is subsystem 4's bit wherever package D reads it, and the knob has a
# consumer if ANY DirectVulkan source reverts a guard on it - today two do, in two files.
mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_REKEY
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "kMGPipeSubsystemMagmaVertexInput")
if (MGL_ITEST_MAGMA_REKEY)
message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen} "
"(${MGL_ITEST_MAGMA_REKEY})")
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1")
else()
message(STATUS "Integration tests: no DirectVulkan source names kMGPipeSubsystemMagmaVertexInput - "
"HandleRecycle.Handles will SKIP on it")
endif()
# P3a's buffer question, and it is NOT the two above. Bits 5/6 re-keyed each backend's
# VERTEX-INPUT memos; a BUFFER only travels as a handle once the resource_* family does
# (P3a for DirectGLES, P7 for DirectVulkan), and until then a buffer's backend twin is
# still reached from the frontend BufferObject. HandleRecycle's buffer case therefore has
# a marker of its own: reading the P2 one would arm its Handles arm on a tree where
# nothing about a buffer is keyed on a handle.
#
# Probed by MGPipeResourceOps - the op table PipeApply.h declares and a backend registers -
# over each backend's whole directory, for the reason the block above gives: the owning
# package picks its own file layout, and a filename probe would answer "no" forever the
# moment it moved the code.
foreach(mglItestResourceBackend DirectGLES DirectVulkan)
mgl_itest_probe_for_symbol(MGL_ITEST_RESOURCE_OPS
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/${mglItestResourceBackend}" "MGPipeResourceOps")
if (MGL_ITEST_RESOURCE_OPS)
message(STATUS "Integration tests: ${mglItestResourceBackend} registers a resource op table "
"(${MGL_ITEST_RESOURCE_OPS})")
list(APPEND MGL_ITEST_CAPABILITY_ENV
"MGITEST_HANDLE_REKEY_RESOURCES_${mglItestResourceBackend}=1")
else()
message(STATUS "Integration tests: no ${mglItestResourceBackend} source names "
"MGPipeResourceOps - HandleRecycle.Handles' buffer case will SKIP on it")
endif()
endforeach()
# The client-side emitter of P3a's map-persistent-roundtrips counter, which is what
# StorageBufferRegrow, LargeArenaAdoption and ResourceSubsystemControl read. Same shape and
# same reason as the CSO emitter probe above: the counter is minted in MG_Impl/Pipe (package
# B), header-only today, so the question is "does any source there emit it", not "does a
# named file exist". Until it does, mpr= is structurally zero and an assertion about it
# would be a statement about nothing.
mgl_itest_probe_for_symbol(MGL_ITEST_RESOURCE_EMITTER
"${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "MapPersistentRoundtrips")
if (MGL_ITEST_RESOURCE_EMITTER)
message(STATUS "Integration tests: map-persistent-roundtrips has an emitter "
"(${MGL_ITEST_RESOURCE_EMITTER})")
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_RESOURCE_EMITTER_PRESENT=1")
else()
message(STATUS "Integration tests: no MG_Impl/Pipe source emits MapPersistentRoundtrips - "
"StorageBufferRegrow's, LargeArenaAdoption's and ResourceSubsystemControl's "
"mpr entries will SKIP")
endif()
mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_ABA
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "PipeHandleAbaControl")
if (MGL_ITEST_MAGMA_ABA)
message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer "
"(${MGL_ITEST_MAGMA_ABA})")
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1")
else()
message(STATUS "Integration tests: no DirectVulkan source names PipeHandleAbaControl - "
"HandleRecycle.AbaControl will SKIP")
endif()
else()
message(STATUS "Integration tests: pull build - HandleRecycle.{Handles,AbaControl}, "
"CsoContentAddressing and ResourceSubsystemControl stay registered (G2) and SKIP: "
"every arm they assert is compiled only under MOBILEGL_PIPE_PUSH")
endif()
mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT
@@ -463,6 +651,20 @@ gtest_discover_tests(MobileGLIntegrationTest
# emulation never runs - the ambient registration would be exercising the un-stripped path
# twice and calling it coverage. With the variable set, the blocks really are emitted with no
# location and the assertion is about the spelling the device gets.
# RESOURCE_LOCK, on this lane and on the three below it, and it is a bug fix rather than a
# precaution. Each of these lanes gives a WHOLE SCENARIO one MOBILEGL_LOG_FILE_PATH, and one case
# in each reads that log back to prove the pinned emulation actually armed. The library opens the
# log fopen(path, "w"), so every process in the lane TRUNCATES it - and under `ctest -j` a sibling
# case of the same lane can truncate it while the arming case is reading, which reads back as "the
# log carries no arming line" and fails a healthy lane. Measured on this tree: three runs of the
# full integration-gpu label at -j 8 produced 4 failures, 0 and 2, always one of these arming
# cases, never the same set twice.
#
# The rule stated above the verify block - a case that reads the log needs a lane whose filter
# selects it alone - would fix it by re-filtering, but that would RENAME the arming entries, and
# an existing ctest name may never disappear (gate G14). A ctest RESOURCE_LOCK named after the log
# is the same guarantee without touching a name: ctest never runs two entries holding the same lock
# at once, so the only processes that can truncate a lane's log are ones nobody is reading it for.
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.UnlocatedIoBlocks."
TEST_FILTER "UnlocatedIoBlockScenario.*"
@@ -470,6 +672,7 @@ gtest_discover_tests(MobileGLIntegrationTest
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
RESOURCE_LOCK unlocated-io-blocks.log
ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}"
)
@@ -586,6 +789,7 @@ gtest_discover_tests(MobileGLIntegrationTest
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
RESOURCE_LOCK primgen-query-reroute.log
ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}"
)
@@ -621,6 +825,7 @@ gtest_discover_tests(MobileGLIntegrationTest
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
RESOURCE_LOCK point-size-demotion-gles.log
ENVIRONMENT "${MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
@@ -631,6 +836,7 @@ gtest_discover_tests(MobileGLIntegrationTest
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
RESOURCE_LOCK point-size-demotion-vulkan.log
ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}"
)
@@ -665,6 +871,376 @@ gtest_discover_tests(MobileGLIntegrationTest
# why the arming case has a lane and a log of its own below, and why neither this file nor CI
# may read the ambient logs as evidence about the entries that ran before the last one. The
# ambient path is kept for post-mortems (and to keep library chatter out of ctest's capture).
# --- G8: the handle ABA, three always-on arms -----------------------------------------
#
# ALWAYS ON, in every build mode, which is deliberate: the Legacy arm asserts today's
# lifetimeId + weak_ptr guards and is meaningful in a pull build, and `ctest -R HandleRecycle`
# has to name the same entries whichever build directory it is pointed at (P2 brief G8 runs it
# against build-verify; D.3 part 1 runs it again as part of the interface-purity gate).
#
# One lane per arm, and each lane names MGITEST_HANDLE_ARM: the arm is not a property of the
# test body, it is the (MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS, MOBILEGL_PIPE_HANDLE_ABA_CONTROL)
# triple the process was launched with, and the scenario skips in the ambient entries because
# none of that is configured there.
#
# Every list APPENDS the common/Vulkan environment for the reason spelled out above the verify
# block: a ctest ENVIRONMENT property REPLACES the job environment for the names it lists, so an
# entry naming only its own knobs would lose the EGL vendor and Vulkan ICD pinning.
#
# The AbaControl arm is DirectVulkan only. The knob defeats the object-identity half of
# DirectVulkan's vertex-input memo keys (VertexInputStateFactory::ComputeHash, its per-VAO memo
# table, and LookupVaoDrawMemo); it steers nothing on DirectGLES, and a lane that configured it
# there would be a permanent skip claiming to be a control.
#
# SO THE BUFFER ABA's CORRUPTION EVIDENCE IS MAGMA-ONLY, AND THAT IS RECORDED RATHER THAN LEFT
# AS AN ABSENCE (gates m10, closed here as a statement rather than as a lane). P3a re-keys the
# BUFFER on DirectGLES - resource_* now dispatches by handle - so the obvious next move is a
# DirectGLES `.AbaControl` lane over ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents.
# It is not a registration-only change and it is therefore not made here: the knob has exactly
# one consumer in the tree (MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h's
# MagmaPipeAbaControlDefeatsIdentity, which is what the MGL_ITEST_MAGMA_ABA probe above looks
# for), so a DirectGLES lane would run with the knob inert, fail to reproduce the corruption it
# asserts, and go RED in an always-on integration-gpu lane - the exact failure mode the header
# of HandleRecycleScenario.cpp records this file already having had once.
#
# What that costs, stated so the next reader does not have to re-derive it: on DirectGLES the
# buffer case's Handles arm proves the re-key does NOT alias, and nothing proves the reproducer
# could still see an aliasing that was reintroduced there. The positive control for that is a
# Features.PipeHandleAbaControl consumer over Espryt's resource slot table - one `if` in
# GetOrCreate / FindByHandle, the way MagmaPipeClaimSlotMemos is Magma's - and it belongs with
# whoever next touches that table, not with a lane registration.
#
# It gets TWO lanes, because there are two arms and the control has to reach the one P2 SHIPS.
# `AbaControl` is D18's lane verbatim (MOBILEGL_PIPE_PUSH=0, the pre-handle arm) and defeats the
# lifetime-id/address guards; `AbaControlHandles` runs the handle arm (MOBILEGL_PIPE_LEGACY_MEMOS=0,
# the default push mask) and defeats the object identity that SELECTS THE SLOT - the key the handle
# arm ships. With only the first lane the control says nothing at all about the re-key: the handle
# arm is not executed under MOBILEGL_PIPE_PUSH=0, so every guard it would have to defeat is in
# another branch.
#
# NEITHER lane exercises the GENERATION half of {slot, gen}, and no lane of this shape can. Magma's
# mint has no death notification and returns a slot only through its age sweep (256/1024 boundaries,
# MagmaPipeArms.h), so the five frame boundaries this scenario issues always hand the replacement a
# brand-new slot at Gen 1; a real reuse needs >= 1024 idle boundaries, which puts the two draws in
# different frames - where the only pixel-visible memo declines by design. The generation is covered
# by the unit suite MG_Test/Pipe/MagmaPipeIdentityTest.cpp instead, which drives a real
# retire -> reuse; MagmaPipeAbaControlDefeatsIdentity carries the measurement.
#
# The two PUSH-ONLY knobs of those arms are set only in a push build, and the lane NAMES are
# unaffected by that (an ENVIRONMENT property is not part of a test's name, so G2 still sees the
# same list in both builds). MOBILEGL_PIPE_LEGACY_MEMOS=0 says "never enter the legacy arm"; in a
# pull build the legacy arm is the ONLY arm and every Track-H subsystem bit is clear, which is
# precisely D14's startup Fatal{PipeLegacyMemosDisabled} condition - so a lane that set it there
# would abort the process before the scenario could report its skip. MOBILEGL_PIPE_HANDLE_ABA_CONTROL
# has no field to parse into in a pull build at all (Config.h declares it under #if MOBILEGL_PIPE_PUSH).
# A lane that needs an arm pins EVERY knob that selects it. A ctest ENVIRONMENT property overrides
# only the variables it names; the rest leak in from the job. The five-part gate's all-pull control
# arm runs `MOBILEGL_PIPE_PUSH=0 ctest -L integration-gpu` over the whole label, and without the
# explicit bitmask below that leaked PUSH=0 turned this lane's LEGACY_MEMOS=0 into D14's armless
# combination: the bring-up aborted, on purpose, and the lane went red for a reason that was never
# about handles. The mask is the PHASE default, not a hand-picked bit, so the lane keeps measuring
# the shape that ships: it was kMGPipeSubsystemsMigratedAtP2 (0x7f) and is now
# kMGPipeSubsystemsMigratedAtP3a (0x1ff, MG_Pipe/MGPipe.h), which adds bit 7 (resources) and bit 8
# (vertex input). Pinning it at 0x7f after P3a would leave the Handles arm asserting the P2 shape
# while the buffer and vertex-input handles it is supposed to be about stayed switched off - a lane
# that still passes and no longer measures the key that ships. Each phase's constant survives as the
# NEXT phase's A/B control, which is what ResourceSubsystemControl's Off lane uses 0x7f for.
if (MOBILEGL_PIPE_PUSH)
set(MGL_ITEST_HANDLES_ARM_KNOBS "MOBILEGL_PIPE_LEGACY_MEMOS=0" "MOBILEGL_PIPE_PUSH=0x1ff")
set(MGL_ITEST_ABA_ARM_KNOBS "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1")
else()
set(MGL_ITEST_HANDLES_ARM_KNOBS "")
set(MGL_ITEST_ABA_ARM_KNOBS "")
endif()
mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS}
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS}
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba" "MOBILEGL_PIPE_PUSH=0"
${MGL_ITEST_ABA_ARM_KNOBS}
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_HANDLES_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba"
${MGL_ITEST_HANDLES_ARM_KNOBS} ${MGL_ITEST_ABA_ARM_KNOBS}
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.HandleRecycle.Handles."
TEST_FILTER "HandleRecycleScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.HandleRecycle.Handles."
TEST_FILTER "HandleRecycleScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.HandleRecycle.Legacy."
TEST_FILTER "HandleRecycleScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.HandleRecycle.Legacy."
TEST_FILTER "HandleRecycleScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControl."
TEST_FILTER "HandleRecycleScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControlHandles."
TEST_FILTER "HandleRecycleScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_HANDLES_ENVIRONMENT}"
)
# --- G12: the CSO content-addressing negative control ---------------------------------
#
# PUSH BUILDS ONLY, and that is the honest scope rather than a convenience: the two counters the
# control reads (CallClass::RenderStateCsoMints / RenderStateCsoBinds) and the `cso[...]` bracket
# of the summary line are both `#if MOBILEGL_PIPE_PUSH` (PipeStats.h, PipeStats.cpp), so in a pull
# build there is no channel to read and an entry here would be a permanent skip.
#
# Each arm gets a LOG PATH OF ITS OWN. The library opens its log fopen(path, "w") - every process
# in a lane truncates it - and these two cases READ that log, so a shared path would have them
# reading a neighbour's bring-up under `ctest -j 4`. Same rule as the arming lane below.
#
# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets the
# workload be bracketed by two swaps and read back as a window covering exactly itself.
#
# Registered in EVERY build, including the pull build where there is no CSO at all, so that
# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a
# pull build MGITEST_PIPE_PUSH_BUILD is absent and both cases skip saying so.
#
# THE MASK IS THE PHASE DEFAULT AND THE CONTROL IS BIT 63, and the two must not be confused. The
# only thing these four lanes are an A/B about is `no CSO content addressing` (bit 63,
# kMGPipeBehaviourNoCsoContentAddressing), which is what separates the On lane from the Off lane.
# Every
# other bit is the build's shipping mask, so it moves with the phase: it was 0x7f
# (kMGPipeSubsystemsMigratedAtP2) and is now 0x1ff (kMGPipeSubsystemsMigratedAtP3a) for the same
# reason MGL_ITEST_HANDLES_ARM_KNOBS above moved. Left pinned at 0x7f after P3a these lanes would
# keep passing while running on a configuration nothing ships - bits 7 (resources) and 8 (vertex
# input) cleared - which is the "still green, no longer measuring the shape that ships" failure
# the Handles-arm comment above rejects. The counters they read (csom / csob) are render-state and
# are steered by neither bit, so raising the mask is behaviour-preserving for what they assert;
# what it buys is that a CSO regression that only shows up with the P3a subsystems on can reach
# them. (contract-review-v1.md item 11, closed here for all three lane families.)
mgl_itest_join_environment(MGL_ITEST_GLES_CSO_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=content-addressed"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=no-content-addressing"
"MOBILEGL_PIPE_PUSH=0x80000000000001ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=content-addressed"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectVulkan.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=no-content-addressing"
"MOBILEGL_PIPE_PUSH=0x80000000000001ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectVulkan.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.CsoContentAddressing.On."
TEST_FILTER "CsoContentAddressingScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_CSO_ON_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.CsoContentAddressing.Off."
TEST_FILTER "CsoContentAddressingScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.CsoContentAddressing.On."
TEST_FILTER "CsoContentAddressingScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.CsoContentAddressing.Off."
TEST_FILTER "CsoContentAddressingScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}"
)
# --- G12: the P3a subsystem A/B, and G10's map-persistent-roundtrips lanes ------------
#
# THE MASKS ARE THE TWO PHASE CONSTANTS, not hand-picked bits: 0x1ff is
# kMGPipeSubsystemsMigratedAtP3a (the push build's default) and 0x7f is
# kMGPipeSubsystemsMigratedAtP2, which is exactly that default with bits 7 (resources) and 8
# (vertex input) cleared. MGPipe.h:79 keeps each phase's constant alive as the next phase's A/B
# control for this reason, and a lane that spelled its own bit pattern would stop being the shape
# that ships the first time the default moved.
#
# DirectGLES only. P3a migrates Espryt's buffer and VAO paths; Magma's buffer path is P7 and
# registers no MGPipeResourceOps, so a DirectVulkan arm would be measuring the client emitter
# against a backend nobody asked to change.
#
# Each reading entry gets a LOG PATH OF ITS OWN, and its ctest entry selects ONE case: the library
# opens the log fopen(path, "w") - every process in a lane truncates it - and these cases READ it.
# Two readers in one lane race under `ctest -j`, and the failure looks exactly like "the counter
# was never emitted". Same rule as the CSO lanes above and the verify arming lane below.
#
# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets a
# workload be bracketed by two swaps and read back as a window covering exactly itself.
#
# Registered in EVERY build, including the pull build where none of the counters exists, so that
# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a
# pull build MGITEST_PIPE_PUSH_BUILD is absent and every one of these cases skips saying so; a
# MOBILEGL_PIPE_PUSH value in the environment of a pull library steers nothing (Config.h declares
# the field inside the push guard), exactly as the HandleRecycle Legacy lanes already rely on.
mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=on"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-on-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=off"
"MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-off-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ResourceSubsystemControl.On."
TEST_FILTER "ResourceSubsystemControlScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ResourceSubsystemControl.Off."
TEST_FILTER "ResourceSubsystemControlScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT}"
)
# G10's two counting entries, one per scenario that has a claim about the counter:
# StorageBufferRegrow asserts N definitions cost N round trips (never one per draw), and
# LargeArenaAdoption asserts one adoption costs exactly one. Each names a single case in its
# TEST_FILTER, for the private-log reason above.
mgl_itest_join_environment(MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=storage-buffer-regrow"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-storage-buffer-regrow-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=large-arena-adoption"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-large-arena-adoption-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.MapPersistentRoundtrips."
TEST_FILTER "StorageBufferRegrowScenario.NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.MapPersistentRoundtrips."
TEST_FILTER "LargeArenaAdoptionScenario.AnAdoptionCostsExactlyOneMapPersistentRoundtrip"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT}"
)
# LargeArenaAdoption's three behavioural cases under BOTH arms of the same A/B. This file is where
# an adopted store's whole life is exercised - the NULL-data definition that adopts it, an
# in-flight SubData, a readback and a GPU write - so if the handle path and the legacy
# BufferBackendOps path disagree about any of it, one of these two lanes goes red and names which.
# No log path: none of these three cases reads one, and giving them one would only add a file for
# four processes to truncate. The fourth case skips in both lanes for exactly that reason, saying
# so.
mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x1ff"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x7f"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ResourceSubsystemOn."
TEST_FILTER "LargeArenaAdoptionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ResourceSubsystemOff."
TEST_FILTER "LargeArenaAdoptionScenario.*"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT}"
)
if (MOBILEGL_PIPE_VERIFY)
# 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb
# boundary and again at every accessor read, which the design budgets at 5-10x.
@@ -0,0 +1,47 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "PipeSlotPeek.h"
#if !defined(__ANDROID__)
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#define MGITEST_PIPE_SLOT_PEEK_LIVE 1
#endif
#endif
namespace MGITest {
#if defined(MGITEST_PIPE_SLOT_PEEK_LIVE)
namespace {
MobileGL::MG_Pipe::MGPipeKind Translate(PipeSlotKind kind) {
switch (kind) {
case PipeSlotKind::Buffer: return MobileGL::MG_Pipe::MGPipeKind::Buffer;
default: return MobileGL::MG_Pipe::MGPipeKind::VertexElementsCso;
}
}
} // namespace
bool PeekPipeSlotLiveCount(PipeSlotKind kind, unsigned* outLive) {
if (outLive == nullptr) return false;
*outLive = static_cast<unsigned>(MobileGL::MG_Pipe::MGPipeSlots().LiveCount(Translate(kind)));
return true;
}
bool PeekPipeSlotHighWater(PipeSlotKind kind, unsigned* outHighWater) {
if (outHighWater == nullptr) return false;
*outHighWater = static_cast<unsigned>(MobileGL::MG_Pipe::MGPipeSlots().HighWater(Translate(kind)));
return true;
}
#else
bool PeekPipeSlotLiveCount(PipeSlotKind, unsigned*) { return false; }
bool PeekPipeSlotHighWater(PipeSlotKind, unsigned*) { return false; }
#endif
} // namespace MGITest
@@ -0,0 +1,44 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// The CLIENT slot allocator's occupancy, read from a scenario.
//
// It exists for one assertion, P3a's C-1: a frontend object that dies must return its
// MGPipeHandle slot WHATEVER BACKEND IS RUNNING. That question has no answer in the GL API -
// the leak it rules out is entirely inside the library, and it is invisible in pixels, in GL
// names and in `glGetError` - so the only honest observable is the allocator's own live count
// and high-water mark. Reading them is what makes the case fail on the backend it actually
// failed on (DirectVulkan, which installs no StateObjectDeathOps) rather than only on the one
// where a backend-owned free happened to exist.
//
// A separate translation unit for BackendCapsPeek.h's reason, verbatim: the scenario sources
// include the GL headers with prototypes and MobileGL's umbrella header is not meant to meet
// them in one file.
#pragma once
namespace MGITest {
// Which client-side object kind to ask about. Mirrors MG_Pipe::MGPipeKind for exactly the
// kinds a scenario has a reason to count, so that the enum does not travel through this
// header and the GL headers together.
enum class PipeSlotKind {
Buffer,
VertexElementsCso,
};
// Live slots of this kind right now, and one past the highest slot ever handed out.
// Both return false, touching nothing, where the allocator is out of reach: in a PULL
// build there is no allocator at all (it is `#if MOBILEGL_PIPE_PUSH`), and on Android this
// module links the shipping libMobileGL.so built -fvisibility=hidden, so no internal symbol
// resolves. A caller that gets false must SKIP rather than pass - "could not look" is not
// "did not leak".
bool PeekPipeSlotLiveCount(PipeSlotKind kind, unsigned* outLive);
bool PeekPipeSlotHighWater(PipeSlotKind kind, unsigned* outHighWater);
} // namespace MGITest
@@ -0,0 +1,94 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Reading ONE PipeStats summary window out of the library's own log, for the scenarios whose
// claim is about a counter rather than about pixels.
//
// WHY THROUGH A LOG FILE AT ALL. MG_Util::PipeStats is internal to the library and this module
// cannot link against it (ScenarioFixture.h has the long version: on Android this binary links
// the SHIPPING libMobileGL.so, built -fvisibility=hidden). The library's `MGPipe stats:` line is
// the only channel, so a lane that wants to read a counter sets MOBILEGL_PIPE_STATS=1,
// MOBILEGL_PIPE_STATS_PERIOD=1 - one line per eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of
// its OWN.
//
// THE LOG PATH HAS TO BE PRIVATE TO ONE CTEST ENTRY, and that is not a style rule: the library
// opens it fopen(path, "w"), so every process launched in a lane TRUNCATES it. Two entries of one
// lane reading the same path race under `ctest -j`, and the shape of the failure is an empty read
// that looks exactly like "the counter was never emitted". So a case that reads a window gets a
// ctest entry whose TEST_FILTER selects that case alone, with a log path nothing else writes -
// the rule PipeVerifyArmingScenario and CsoContentAddressingScenario already follow.
//
// THE WINDOW IS "SINCE THE PREVIOUS LINE" (PipeStats::FormatWindowLine), so the caller closes the
// setup window with a swap, runs the workload, swaps again, and reads the LAST line - which then
// covers the workload and nothing else.
#pragma once
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iterator>
#include <string>
namespace MGITest::PipeStatsWindow {
// The lane's private log path, or empty when the lane configured none.
inline std::string LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::string(path) : std::string();
}
inline std::string ReadWholeFile(const std::string& path) {
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
// The last summary line in the log, verbatim. `found` is false when the library never emitted
// one, which is a different failure from "the counter read zero" and has to be reported as
// one: it means the stats channel never reached the process, not that the workload did
// nothing.
struct Window {
bool found = false;
std::string line;
};
inline Window Last(const std::string& log) {
Window window;
const std::string marker = "MGPipe stats:";
const std::size_t at = log.rfind(marker);
if (at == std::string::npos) return window;
const std::size_t end = log.find('\n', at);
window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at);
window.found = true;
return window;
}
inline Window LastFromLaneLog() { return Last(ReadWholeFile(LibraryLogPath())); }
// One counter out of that line, by its short name ("mpr", "draws", "csom"), or -1 when the
// line does not carry it. The search includes the SEPARATOR before the name and the `=` after
// it, so "draws" cannot match "draws/f=" and "mpr" cannot match a longer name ending in it -
// a substring match here would read a neighbouring counter's value and report it as this
// one's, which is the one way a counter assertion can be wrong without ever failing.
inline long long CounterOrAbsent(const Window& window, const char* shortName) {
if (!window.found) return -1;
// A counter is preceded either by a space (` mpr=`, ` draws=`) or by its bracket's
// opening (`cso[csom=`, `bytes/f[stage-buffer=`); nothing in the line is preceded by
// anything else.
for (const char* prefix : {" ", "["}) {
const std::string key = std::string(prefix) + shortName + "=";
const std::size_t at = window.line.find(key);
if (at == std::string::npos) continue;
return std::strtoll(window.line.c_str() + at + key.size(), nullptr, 10);
}
return -1;
}
} // namespace MGITest::PipeStatsWindow
@@ -0,0 +1,329 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - THE CSO CONTENT-ADDRESSING NEGATIVE CONTROL (gate G12).
//
// P2's render-state CSO is content-addressed: the client hashes the 396 pipeline bytes, probes a
// 64-entry cache, memcmps a hash hit and reuses the handle. The whole design is measured against
// a knob that turns that off - kMGPipeBehaviourNoCsoContentAddressing, bit 63 of the runtime
// MOBILEGL_PIPE_PUSH bitmask - so that "push is slower" can be told apart from "the CSO design is
// slower" (P2 brief D.4.5). A measurement knob has one characteristic failure mode: it stops
// steering anything and every later number is quietly taken against a switch that does nothing.
// This file is the entry that cannot let that happen.
//
// WHAT IT ASSERTS, per arm, and why those are the right shapes:
//
// content-addressed (MOBILEGL_PIPE_PUSH=0x7f)
// A Blaze3D blend toggle - enable / draw / disable / draw, N times, which is the workload
// the CsoCache exists for (ARCHITECTURE.md 5.1: the push happens at validate rather than in
// the setter precisely because Blaze3D brackets every batch this way) - visits exactly TWO
// distinct pipeline subsets. So the mint count must stay small and BOUNDED while the bind
// count grows with the draws: csom << csob.
//
// no content addressing (MOBILEGL_PIPE_PUSH=0x800000000000007f)
// Every pipeline-version change mints a fresh CSO and the map is never probed, so mint and
// bind must move together: csom == csob. This is the assertion a dead switch fails - with
// the bit ignored, this arm would report csom << csob just like the other one.
//
// both arms
// The PIXELS must not move. The quad is drawn with alpha 1.0 through
// GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA, so the blended and unblended draws produce the
// same colour by construction and the readback is the same image in both arms and after
// every toggle. "The counters moved and the picture did not" is the whole claim.
//
// HOW THE COUNTERS ARE READ. MG_Util::PipeStats is internal to the library and this module cannot
// link against it (ScenarioFixture.h explains why: on Android this binary links the SHIPPING
// libMobileGL.so, built -fvisibility=hidden). The library's own summary line is the only channel,
// so each lane sets MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 - one line per
// eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of its OWN. The log path has to be private: the
// library opens it fopen(path, "w"), so every process in a lane truncates it, and a whole-file
// read in a shared lane races a neighbour's bring-up. That is the same rule, and the same
// remedy, as PipeVerifyArmingScenario's arming lane.
//
// The window a summary line reports is "since the previous line" (PipeStats::FormatWindowLine), so
// the workload runs inside ONE frame: a swap before it closes the setup window, and the swap after
// it emits a line whose csom / csob cover the toggle loop and nothing else.
//
// WHY IT CAN SKIP. The counters are minted by the client-side tracker (P2 package B), and this
// file is written against the P2 contract commit, before that package lands. Until the tracker
// exists there is no CSO to mint, csom is structurally 0 and an assertion about its ratio to csob
// would be a statement about nothing. The build answers the question rather than a hand-maintained
// list: MG_IntegrationTest/CMakeLists.txt greps every source under MG_Impl/Pipe/ for the two
// counters' names and passes the answer in as MGITEST_PIPE_TRACKER_PRESENT, with a
// CONFIGURE_DEPENDS on that directory and on each file it finds so the answer cannot go stale.
// It is a CONTENT probe, not a filename probe, precisely so that the owning package keeps control
// of its own file layout - it implements the tracker and the cache header-only today, and a glob
// for `Tracker.cpp` would have kept this control skipping forever after that package landed, with
// a reason that had become false. When an emitter lands the arms arm themselves; until then the
// entries are registered, visible and SKIPPED with the reason - never absent, and never green for
// having asserted nothing.
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Set by the two CsoContentAddressing. ctest entries and by nothing else; a harness
// marker, never read by the library. Its absence means an ambient entry, where neither
// the stats channel nor a private log path is configured.
constexpr const char* kLaneMarker = "MGITEST_CSO_LANE";
constexpr const char* kLaneContentAddressed = "content-addressed";
constexpr const char* kLaneNoContentAddressing = "no-content-addressing";
// Toggle pairs per frame. 8 is small enough to keep the frame cheap and large enough that
// "mints stay bounded" and "mints track binds" are different numbers by a wide margin.
constexpr int kTogglePairs = 8;
constexpr int kDrawsPerFrame = kTogglePairs * 2;
// The blend toggle visits two distinct pipeline subsets, so two CSOs. The bound is
// deliberately a little looser than 2: a future chunk-table change could legitimately
// split one of them, and the claim being pinned here is "bounded, not per-draw".
constexpr long long kMaxDistinctCsos = 4;
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
)";
constexpr const char* kFS = R"(#version 330 core
out vec4 oColor;
void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); }
)";
constexpr int kInset = 2;
bool BuildMarkerIsSet(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] == '1' && value[1] == '\0';
}
std::string LaneName() {
const char* lane = std::getenv(kLaneMarker);
return lane != nullptr ? std::string(lane) : std::string();
}
std::string LibraryLogPath() {
const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH");
return (path != nullptr && *path != '\0') ? std::string(path) : std::string();
}
std::string ReadWholeFile(const std::string& path) {
if (path.empty()) return {};
std::ifstream file(path, std::ios::binary);
if (!file.good()) return {};
return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
// One window's CSO counters, as the library printed them.
struct CsoWindow {
bool found = false;
long long mints = -1;
long long binds = -1;
std::string line;
};
// Parses `... cso[csom=<N> csob=<M>] ...` out of the LAST "MGPipe stats:" line in the log.
// The last line, because the window a line reports is "since the previous line" and the
// caller closes the setup window with a swap before the workload.
CsoWindow LastCsoWindow(const std::string& log) {
CsoWindow window;
const std::string marker = "MGPipe stats:";
std::size_t at = log.rfind(marker);
if (at == std::string::npos) return window;
const std::size_t end = log.find('\n', at);
window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at);
const std::string mintKey = "csom=";
const std::string bindKey = "csob=";
const std::size_t mintAt = window.line.find(mintKey);
const std::size_t bindAt = window.line.find(bindKey);
if (mintAt == std::string::npos || bindAt == std::string::npos) return window;
window.mints = std::strtoll(window.line.c_str() + mintAt + mintKey.size(), nullptr, 10);
window.binds = std::strtoll(window.line.c_str() + bindAt + bindKey.size(), nullptr, 10);
window.found = true;
return window;
}
class CsoContentAddressingScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_lane = LaneName();
std::string error;
m_program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(m_program, 0u) << error;
const float quad[12] = {-1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f,
-1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f};
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glGenBuffers(1, &m_vbo);
glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind";
RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str());
}
void TearDown() override {
if (!Ready()) return;
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
if (m_vbo != 0) glDeleteBuffers(1, &m_vbo);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
}
// GTEST_SKIP() returns from the function it is written in, so this cannot report
// through a return value; every caller pairs it with `if (IsSkipped()) return;`.
void SkipUnlessTheLaneIsAssertableHere() {
if (m_lane.empty()) {
GTEST_SKIP() << "runs only in its own lane: the two CsoContentAddressing. ctest entries set "
"MGITEST_CSO_LANE together with the MOBILEGL_PIPE_PUSH bitmask, "
"MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private "
"MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient "
"entries, and the ambient log is shared, so a read here would race.";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so there is no "
"render-state CSO to mint, no cso[] bracket in the summary line and "
"nothing for the content-addressing bit to steer. The entry is "
"registered here anyway so that `ctest -L integration-gpu` names the "
"same tests in the pull build and the push build (gate G2).";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_TRACKER_PRESENT")) {
GTEST_SKIP() << "the CSO counters have no emitter in this build: no source under "
"MobileGL/MG_Impl/Pipe/ names RenderStateCsoMints or "
"RenderStateCsoBinds, so nothing mints or binds a render-state CSO "
"and csom / csob are structurally zero. P2 package B owns the tracker "
"and the CSO cache; this entry arms itself when they land, whatever "
"files that package chooses to put them in.";
return;
}
if (LibraryLogPath().empty()) {
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's summary "
"line is the only channel this module has for reading PipeStats";
return;
}
}
// enable / draw / disable / draw, kTogglePairs times, entirely inside one frame.
// Returns the readback taken at the end of that frame, before the swap.
Image RunBlendToggleFrame() {
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);
for (int i = 0; i < kTogglePairs; ++i) {
glEnable(GL_BLEND);
glDrawArrays(GL_TRIANGLES, 0, 6);
glDisable(GL_BLEND);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
return image;
}
std::string m_lane;
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_vbo = 0;
};
// ONE case per lane, and that is a hard constraint rather than a style choice.
//
// This case READS the library log, and the log is a per-LANE resource: the library opens it
// fopen(path, "w"), so every process in a lane truncates it. A second case in this lane would
// therefore race this one under `ctest -j`, and the shape of the failure is a silent, empty
// read that looks exactly like "the counters were never emitted". Splitting the plumbing
// assertion into its own case would have bought a clearer failure message and paid for it
// with a flake in the thing the message is about. The plumbing is asserted first, with its
// own message, inside this one process instead.
TEST_F(CsoContentAddressingScenario, TheBlendToggleMintsBoundedlyWithContentAddressingAndPerBindWithout) {
if (!Ready()) return;
SkipUnlessTheLaneIsAssertableHere();
if (IsSkipped()) return;
Gl().EndFrame(); // close the setup window
const Image first = RunBlendToggleFrame();
const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath()));
// The plumbing first, with its own message, so a counter-ratio failure below can never
// be confused with "the lane never turned the stats channel on".
ASSERT_TRUE(window.found)
<< "no 'MGPipe stats:' line carrying cso[csom= csob=] in " << LibraryLogPath()
<< ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting "
"here) and the cso[] bracket is unconditional inside that #if, so it cannot be "
"missing for a build reason: either MOBILEGL_PIPE_STATS / "
"MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or no summary line was "
"emitted at all because nothing reached PipeStats::OnPresent.";
RecordProperty("cso_line", window.line.c_str());
// Every draw in the frame changed the pipeline subset, so every draw is a bind. This
// is the denominator both arms are read against; without it, "csom == csob" would also
// be satisfied by a frame in which neither happened at all.
ASSERT_GE(window.binds, static_cast<long long>(kDrawsPerFrame))
<< "the toggle frame issued " << kDrawsPerFrame
<< " draws whose pipeline subset alternates, so it must have issued at least that many "
"render-state binds. It reported: "
<< window.line;
if (m_lane == kLaneContentAddressed) {
EXPECT_LE(window.mints, kMaxDistinctCsos)
<< "with content addressing on, enable/draw/disable/draw x " << kTogglePairs
<< " visits two distinct pipeline subsets and must mint a bounded number of CSOs, then "
"reuse them. It reported: "
<< window.line;
EXPECT_LT(window.mints, window.binds)
<< "with content addressing on the cache must be answering binds it did not mint. "
<< window.line;
} else if (m_lane == kLaneNoContentAddressing) {
EXPECT_EQ(window.mints, window.binds)
<< "kMGPipeBehaviourNoCsoContentAddressing (bit 63 of MOBILEGL_PIPE_PUSH) must make every "
"bind mint a fresh CSO - the map is never probed and no handle is ever reused. Equal "
"counters are the only reading that proves the bit STEERED anything: if it were "
"ignored, this arm would report the same bounded mint count as the other one. It "
"reported: "
<< window.line;
} else {
FAIL() << "unknown " << kLaneMarker << " value '" << m_lane << "'";
}
// ... and the picture is the same in both arms and after every toggle. The quad is
// opaque, so the blended and unblended draws agree by construction.
EXPECT_TRUE(RegionIsMostly(first, kInset, first.Width() - kInset, kInset, first.Height() - kInset,
"green", 0.0, "the blend-toggle frame [" + m_lane + "]"));
const Image second = RunBlendToggleFrame();
EXPECT_TRUE(second == first)
<< "the second toggle frame does not match the first: " << second.ByteDiffCount(first)
<< " bytes differ. The CSO path must not change what is drawn.";
}
} // namespace
} // namespace MGITest
File diff suppressed because it is too large Load Diff
@@ -25,13 +25,31 @@
// * GetBufferSubData reads back the latest CPU write - the shadow IS the map;
// * a compute-shader write through an SSBO binding of the same arena is read
// back - the GPU-written path for adopted stores (glFinish + direct read).
//
// P3a (gate G10, G12) adds a fourth case and two more lanes, and neither of them
// changes what the three above assert:
//
// * AnAdoptionCostsExactlyOneMapPersistentRoundtrip counts the acquisition.
// ARCHITECTURE.md:474 prices the adopted store at one round trip per STORAGE
// DEFINITION; `map-persistent-roundtrips` counts every map_persistent
// emission, mint or decline (D-B2), so one definition plus a frame of draws
// must publish exactly one. It reads the library's summary line, so it needs
// a lane with the stats channel and a private log path, and it SKIPS - with
// the reason - anywhere else and on any tree that does not emit the counter.
// * the three original cases are registered TWICE MORE, with P3a's resource and
// vertex-input subsystem bits set and cleared, because this file is where an
// adopted store's whole life is exercised: definition, in-flight SubData,
// readback and a GPU write. If the handle path and the legacy BufferBackendOps
// path disagree about any of it, one of the two arms goes red here.
#include <array>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/PipeStatsWindow.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
@@ -73,6 +91,19 @@ layout(std430, binding = 0) buffer Arena { uint word; };
void main() { word = 0xC0FFEEu; }
)";
// Set by the MapPersistentRoundtrips. ctest entry and by nothing else; a harness marker,
// never read by the library.
constexpr const char* kLaneMarker = "MGITEST_MPR_LANE";
// Draws issued against the arena inside the counted window. One definition, many draws:
// "one per definition" (1) and "one per draw" (kDrawsInTheWindow) have to be different
// numbers or the assertion cannot tell them apart.
constexpr int kDrawsInTheWindow = 5;
bool BuildMarkerIsSet(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] == '1' && value[1] == '\0';
}
struct Vertex {
float x, y;
float r, g, b;
@@ -102,6 +133,12 @@ void main() { word = 0xC0FFEEu; }
// The NULL-data definition is the adoption point (and Minecraft's
// arena-creation idiom).
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
ConfigureVertexArray(m_vao);
}
void ConfigureVertexArray(GLuint vao) {
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
@@ -173,15 +210,49 @@ void main() { word = 0xC0FFEEu; }
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
}
void DrawQuad() {
void DrawQuad(GLuint vao = 0) {
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glBindVertexArray(m_vao);
glBindVertexArray(vao != 0 ? vao : m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
// GTEST_SKIP() returns from the function it is written in, so this cannot report
// through a return value; the caller pairs it with `if (IsSkipped()) return;`.
void SkipUnlessTheRoundtripCounterIsReadableHere() {
if (std::getenv(kLaneMarker) == nullptr) {
GTEST_SKIP() << "runs only in its own lane: the MapPersistentRoundtrips. ctest entry "
"sets MGITEST_MPR_LANE together with MOBILEGL_PIPE_PUSH's P3a mask, "
"MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private "
"MOBILEGL_LOG_FILE_PATH. The ambient entries and the two subsystem "
"arms configure none of that, and their log is shared - a read there "
"would race a neighbour's bring-up.";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so "
"CallClass::MapPersistentRoundtrips does not exist and the summary "
"line carries no mpr=. The entry stays registered so that "
"`ctest -L integration-gpu` names the same tests in both builds (G2).";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) {
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
"MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing "
"emits map_persistent and mpr= is structurally zero. P3a package B "
"owns that emitter; this entry arms itself when it lands.";
return;
}
if (PipeStatsWindow::LibraryLogPath().empty()) {
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's "
"summary line is the only channel this module has for reading "
"PipeStats";
return;
}
}
std::array<unsigned char, 4> CenterPixel() {
std::array<unsigned char, 4> px = {0, 0, 0, 0};
glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE,
@@ -222,6 +293,97 @@ void main() { word = 0xC0FFEEu; }
EXPECT_LT(px[0], 50) << "the draw still shows the previous frame's bytes";
}
// Respecifying a frontend buffer preserves its VAO attachments even when the
// backend replaces the adopted store's GL name. Keep every attribute binding
// unchanged so a stale backend VAO cannot be repaired by a frontend rebind.
TEST_F(LargeArenaAdoptionScenario, RespecifiedVertexArenaKeepsVaoBindings) {
if (!Ready() || IsSkipped()) return;
UploadQuad(1.f, 0.f, 0.f);
DrawQuad();
ASSERT_GT(CenterPixel()[0], 200);
ASSERT_EQ(FirstGLError(), 0u);
GLuint otherVao = 0;
glGenVertexArrays(1, &otherVao);
ConfigureVertexArray(otherVao);
DrawQuad(otherVao);
EXPECT_GT(CenterPixel()[0], 200);
EXPECT_EQ(FirstGLError(), 0u);
constexpr std::array<GLsizeiptr, 3> sizes = {
kArenaBytes, kArenaBytes + 4096, kArenaBytes - 4096,
};
constexpr std::array<std::array<float, 3>, 3> colors = {{
{0.f, 1.f, 0.f}, {0.f, 0.f, 1.f}, {1.f, 0.f, 0.f},
}};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferData(GL_ARRAY_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
UploadQuad(colors[i][0], colors[i][1], colors[i][2]);
// The unbound VAO can retain the deleted store; the current VAO's
// attachments can be cleared by deletion. Both must be repaired.
for (GLuint vao : {m_vao, otherVao}) {
SCOPED_TRACE(vao);
DrawQuad(vao);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
for (std::size_t channel = 0; channel < 3; ++channel) {
if (colors[i][channel] != 0.f) {
EXPECT_GT(px[channel], 200) << "VAO did not fetch the replacement vertex store";
} else {
EXPECT_LT(px[channel], 50) << "VAO still fetched the previous vertex store";
}
}
}
}
glDeleteVertexArrays(1, &otherVao);
}
TEST_F(LargeArenaAdoptionScenario, RespecifiedIndexArenaKeepsVaoBinding) {
if (!Ready() || IsSkipped()) return;
auto vertices = QuadVertices(1.f, 0.f, 0.f);
const auto green = QuadVertices(0.f, 1.f, 0.f);
vertices.insert(vertices.end(), green.begin(), green.end());
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
GLuint indices = 0;
glGenBuffers(1, &indices);
glBindVertexArray(m_vao);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, indices);
// Redefine through COPY_WRITE_BUFFER so the element binding slot never
// changes. The small final store also exercises returning to shadow storage.
glBindBuffer(GL_COPY_WRITE_BUFFER, indices);
constexpr std::array<GLsizeiptr, 4> sizes = {
kArenaBytes, kArenaBytes, kArenaBytes + 4096, 4096,
};
for (std::size_t i = 0; i < sizes.size(); ++i) {
SCOPED_TRACE(sizes[i]);
const GLuint first = (i % 2) == 0 ? 0u : 6u;
const std::array<GLuint, 6> elements = {
first, first + 1, first + 2, first + 3, first + 4, first + 5,
};
glBufferData(GL_COPY_WRITE_BUFFER, sizes[i], nullptr, GL_DYNAMIC_DRAW);
glBufferSubData(GL_COPY_WRITE_BUFFER, 0, sizeof(elements), elements.data());
glViewport(0, 0, Gl().Width(), Gl().Height());
glClearColor(0.f, 0.f, 0.f, 1.f);
glClear(GL_COLOR_BUFFER_BIT);
glUseProgram(m_program);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[first == 0 ? 0 : 1], 200) << "VAO did not fetch the replacement index store";
EXPECT_LT(px[first == 0 ? 1 : 0], 50) << "VAO still fetched the previous index store";
}
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
glBindBuffer(GL_COPY_WRITE_BUFFER, 0);
glDeleteBuffers(1, &indices);
}
// The shadow IS the mapping: a readback straight after a CPU write must hand
// back exactly those bytes.
TEST_F(LargeArenaAdoptionScenario, ReadbackSeesTheLatestCpuWrite) {
@@ -275,4 +437,72 @@ void main() { word = 0xC0FFEEu; }
<< "the compute write into the adopted arena did not reach the CPU readback";
}
// G10, the per-adoption half: ONE storage definition of an arena costs ONE map_persistent
// emission, however many draws read it afterwards.
//
// The arena SetUp defined is deliberately re-defined inside the counted window rather than
// measured from outside it: the window a summary line reports is "since the previous line",
// so the definition has to happen between the two swaps that bracket it, and a case that
// counted SetUp's definition would be reading a window it did not control.
//
// ONE reading case per lane, for the reason PipeStatsWindow.h gives: the library truncates the
// log per process, so two readers in a lane race under `ctest -j`.
TEST_F(LargeArenaAdoptionScenario, AnAdoptionCostsExactlyOneMapPersistentRoundtrip) {
if (!Ready() || IsSkipped()) return;
SkipUnlessTheRoundtripCounterIsReadableHere();
if (IsSkipped()) return;
Gl().EndFrame(); // close the setup window, SetUp's own definition included
// One definition of a store past the 16 MiB adoption threshold, taken by RE-SPECIFYING
// SetUp's arena while m_vao's attributes are still pointing into it - and the attributes
// are deliberately NOT re-declared afterwards, so the draws below can only land if the
// backend VAO followed the new store on its own.
//
// That is the hard shape on purpose. It was routed around in the first cut of this file
// because feat/disaggregated did not yet carry `dev`'s d7655247 ("rebind VAOs when an
// adopted buffer is respecified - the immediate retire path forgot the buffer-id
// generation") and the workload was a hard SIGSEGV inside the vertex fetch on the first
// draw after the re-specification. ID-9 merged that fix (feat/disaggregated 5cb826b0) and
// requires it to hold in BOTH the legacy and the handle arm of the respecify/retire path,
// so this workload counts the path rather than avoiding it: under the
// ResourceSubsystemOn./Off. lanes the same body runs on both arms, and a handle arm that
// re-implemented the retire without the rebind is a crash here rather than a silent
// divergence found on device.
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
ASSERT_EQ(FirstGLError(), 0u) << "re-specifying the arena inside the counted window failed";
// ... and then a frame's worth of traffic against it, of the shape the arena exists for:
// a SubData per draw, every one of which lands in the adopted mapping and none of which
// may acquire it again.
for (int draw = 0; draw < kDrawsInTheWindow; ++draw) {
UploadQuad(0.f, 1.f, 0.f);
DrawQuad();
}
const auto px = CenterPixel();
EXPECT_EQ(FirstGLError(), 0u);
EXPECT_GT(px[1], 200) << "the draws inside the counted window never landed, so the count below "
"would be a number about nothing";
Gl().EndFrame(); // the swap that emits the window covering exactly the work above
const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog();
ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath()
<< ": either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did not "
"reach the process, or nothing reached PipeStats::OnPresent.";
RecordProperty("stats_line", window.line.c_str());
const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr");
ASSERT_GE(roundtrips, 0) << "the summary line carries no mpr= field: " << window.line;
EXPECT_EQ(roundtrips, 1)
<< "one storage definition of an adopted arena is one map_persistent emission "
"(ARCHITECTURE.md:474, D-B2: mint OR decline, both need an answer from the resource "
"owner). This window defined the arena once and drew from it "
<< kDrawsInTheWindow << " times, so 1 is the whole cost; " << kDrawsInTheWindow
<< " would mean the acquisition moved onto the draw path - the ~167 ms/arena hiccup this "
"adoption removed, re-introduced - and 0 would mean the emission stopped happening. It "
"reported: "
<< window.line;
}
} // namespace MGITest
@@ -0,0 +1,327 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ResourceSubsystemControlScenario.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
//
// Scenario - THE P3a SUBSYSTEM A/B IS REAL (gate G12).
//
// P3a migrates two subsystems: kMGPipeSubsystemResources (bit 7, the resource_* family) and
// kMGPipeSubsystemVertexInput (bit 8, vertex elements / buffers / index). The push build's default
// mask becomes kMGPipeSubsystemsMigratedAtP3a = 0x1ff, and P2's 0x7f survives as the control that
// clears exactly those two bits - MGPipe.h:79's rule that every phase's constant keeps meaning what
// it meant, so an operator's recorded mask is still readable a phase later.
//
// That A/B is what every "push vs pull" number in MEASUREMENTS.md is taken against, and it has one
// characteristic failure mode: the bits stop steering anything, both arms run the same code, and
// every later comparison is quietly taken against a switch that does nothing. This file is the
// entry that cannot let that happen.
//
// WHAT IT ASSERTS, per arm:
//
// on (MOBILEGL_PIPE_PUSH=0x1ff)
// The client emits map_persistent for every definition of a store past
// BufferObject::TryAdoptLargeStorage's 16 MiB threshold, so the window's
// map-persistent-roundtrips (`mpr=`) equals the number of definitions in it - one per
// storage definition, mint or decline (D-B2).
//
// off (MOBILEGL_PIPE_PUSH=0x7f, P2's default = P3a's subsystems cleared)
// The frontend dispatch falls through to the legacy BufferBackendOps arm, nothing is emitted
// through the resource family, and mpr= must read ZERO. This is the reading a dead switch
// fails: with bit 7 ignored, this arm would report the same non-zero count as the other one.
//
// both arms
// THE PIXELS MUST NOT MOVE. The arena is filled with one solid-colour quad and drawn, and
// both arms must read back that colour. "The counters moved and the picture did not" is the
// whole claim - a switch that changed what is drawn would not be an A/B, it would be a bug.
//
// WHY IT CAN SKIP. The counter is emitted by the client-side resource tracker (P3a package B), and
// this file is written against the P3a contract commit, before that package lands. Until then
// nothing emits map_persistent, mpr= is structurally zero in BOTH arms, and an assertion about the
// difference would be a statement about nothing. The build answers the question rather than a
// hand-maintained list: MG_IntegrationTest/CMakeLists.txt greps every source under MG_Impl/Pipe/
// for the counter's name and passes the answer in as MGITEST_PIPE_RESOURCE_EMITTER_PRESENT, with a
// CONFIGURE_DEPENDS on that directory and on each file it finds so the answer cannot go stale. It
// is a CONTENT probe, not a filename probe, so the owning package keeps control of its own file
// layout - P3a's new client files are headers (D-N), and a glob for `ResourceTracker.cpp` would
// have kept this control skipping forever with a reason that had become false.
//
// DirectGLES ONLY, and that is the honest scope: P3a migrates Espryt's buffer and VAO paths.
// Magma's buffer path is P7 and registers no MGPipeResourceOps, so a DirectVulkan lane here would
// be measuring the client emitter against a backend that has not been asked to change - which is
// a real question, but it is P7's, not this control's.
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/PipeStatsWindow.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
#undef GLAPI
#endif
#define GL_GLEXT_PROTOTYPES
#include <GL/gl.h>
#include <GL/glcorearb.h>
#undef GL_GLEXT_PROTOTYPES
namespace MGITest {
namespace {
// Set by the two ResourceSubsystemControl. ctest entries and by nothing else; a harness
// marker, never read by the library. Its absence means an ambient entry, where neither the
// stats channel nor a private log path is configured.
constexpr const char* kLaneMarker = "MGITEST_RESOURCE_SUBSYSTEM_LANE";
constexpr const char* kLaneOn = "on";
constexpr const char* kLaneOff = "off";
// Past BufferObject::TryAdoptLargeStorage's 16 MiB threshold, so the store is offered for
// adoption at all; the vertex payload sits deep inside it so a clamped or aliased adopted
// range would miss it. Same shape as LargeArenaAdoptionScenario, deliberately: this
// control's workload has to be one the buffer path really takes.
constexpr GLsizeiptr kArenaBytes = GLsizeiptr(20) * 1024 * 1024;
constexpr GLintptr kVertexOffset = GLintptr(16) * 1024 * 1024;
// Two definitions and several draws each, so "one per definition", "one per draw" and
// "none at all" are three different numbers.
//
// ONE ARENA DEFINED TWICE, not two arenas defined once each: the second definition
// RE-SPECIFIES a store whose bytes the VAO's attributes are already pointing into, and
// the attributes are not re-declared afterwards. That makes this control also the place
// where the respecify/retire path is exercised on BOTH arms of the A/B, which is what
// ID-9 asks for: `dev`'s d7655247 ("rebind VAOs when an adopted buffer is respecified -
// the immediate retire path forgot the buffer-id generation") arrived in
// feat/disaggregated with the 5cb826b0 merge, and the handle arm duplicates that retire
// core, so an arm that forgot the rebind must be visible somewhere. Here it is a dead
// draw or a fault, not a silent divergence. The first cut of this file routed around the
// path because the fix was not yet in this branch's history and the workload reproduced
// as a hard SIGSEGV in the vertex fetch; that detour is what ID-9 supersedes.
//
// The COUNT is unaffected by the change: two storage definitions either way, which is
// what ARCHITECTURE.md:474 prices.
constexpr int kDefinitionsInTheWindow = 2;
constexpr int kDrawsPerDefinition = 3;
constexpr int kInset = 2;
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
in vec3 aColor;
out vec3 vColor;
void main() {
vColor = aColor;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFS = R"(#version 330 core
in vec3 vColor;
out vec4 oColor;
void main() { oColor = vec4(vColor, 1.0); }
)";
struct Vertex {
float x, y;
float r, g, b;
};
std::vector<Vertex> Quad(float r, float g, float b) {
return {
{-1.0f, -1.0f, r, g, b}, {1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b},
{-1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, {-1.0f, 1.0f, r, g, b},
};
}
bool BuildMarkerIsSet(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] == '1' && value[1] == '\0';
}
std::string LaneName() {
const char* lane = std::getenv(kLaneMarker);
return lane != nullptr ? std::string(lane) : std::string();
}
class ResourceSubsystemControlScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
m_lane = LaneName();
std::string error;
m_program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(m_program, 0u) << error;
// The VAO only. The arena is created and defined inside the counted window - the
// window a summary line reports is "since the previous line", so a definition
// taken in SetUp would be counted in a window this case does not control - and
// its attribute pointers are declared only once the store exists, because an
// attribute whose offset is 16 MiB into a store that has not been defined yet is
// a range no driver has to accept.
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str());
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
if (m_arena != 0) glDeleteBuffers(1, &m_arena);
m_arena = 0;
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_program != 0) glDeleteProgram(m_program);
}
// GTEST_SKIP() returns from the function it is written in, so this cannot report
// through a return value; the caller pairs it with `if (IsSkipped()) return;`.
void SkipUnlessTheLaneIsAssertableHere() {
if (m_lane.empty()) {
GTEST_SKIP() << "runs only in its own lane: the two ResourceSubsystemControl. ctest "
"entries set MGITEST_RESOURCE_SUBSYSTEM_LANE together with the "
"MOBILEGL_PIPE_PUSH bitmask that arm means, MOBILEGL_PIPE_STATS=1, "
"MOBILEGL_PIPE_STATS_PERIOD=1 and a private MOBILEGL_LOG_FILE_PATH. "
"None of that is configured in the ambient entries, and the ambient "
"log is shared, so a read here would race.";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH: there are no "
"subsystem bits to clear, CallClass::MapPersistentRoundtrips does "
"not exist and the summary line carries no mpr=. The entry is "
"registered here anyway so that `ctest -L integration-gpu` names the "
"same tests in the pull build and the push build (gate G2).";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) {
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
"MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing "
"emits map_persistent, mpr= is structurally zero in BOTH arms and "
"the difference between them is not observable yet. P3a package B "
"owns the client-side resource tracker; this control arms itself "
"when it lands, whatever file that package puts the emitter in.";
return;
}
if (PipeStatsWindow::LibraryLogPath().empty()) {
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's "
"summary line is the only channel this module has for reading "
"PipeStats";
return;
}
}
// ONE storage definition - the NULL-data glBufferData past the adoption threshold,
// which is Minecraft's arena-creation idiom and the adoption point - then a few
// draws. Entirely inside one frame, so one summary window covers exactly this.
//
// The attribute pointers are declared ONCE, on the first definition, and never again:
// definition 0 creates the store, every later index RE-SPECIFIES it under the live
// VAO. Re-declaring them afterwards would re-sync the VAO by hand and hide the thing
// the second definition is here to exercise (see kDefinitionsInTheWindow above).
void DefineTheArenaAndDrawFromIt(int index, float r, float g, float b) {
if (index == 0) glGenBuffers(1, &m_arena);
glBindBuffer(GL_ARRAY_BUFFER, m_arena);
glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW);
const std::vector<Vertex> vertices = Quad(r, g, b);
glBufferSubData(GL_ARRAY_BUFFER, kVertexOffset,
GLsizeiptr(vertices.size() * sizeof(Vertex)), vertices.data());
if (index == 0) {
glBindVertexArray(m_vao);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset));
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<void*>(kVertexOffset + 2 * sizeof(float)));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
}
glUseProgram(m_program);
for (int draw = 0; draw < kDrawsPerDefinition; ++draw) {
glDrawArrays(GL_TRIANGLES, 0, 6);
}
}
std::string m_lane;
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_arena = 0;
};
// ONE case per lane, and it is a constraint rather than a preference: this case READS the
// library log, the log is a per-LANE resource (the library opens it fopen(path, "w"), so
// every process in a lane truncates it), and a second case here would race this one under
// `ctest -j` with a failure indistinguishable from "the counter was never emitted". The
// plumbing is asserted first, with its own message, inside this one process.
TEST_F(ResourceSubsystemControlScenario, ClearingTheP3aBitsStopsTheEmissionsAndNotThePixels) {
if (!Ready()) return;
SkipUnlessTheLaneIsAssertableHere();
if (IsSkipped()) return;
BindDefaultFramebuffer();
Gl().EndFrame(); // close the setup window: everything below is one window
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
for (int definition = 0; definition < kDefinitionsInTheWindow; ++definition) {
DefineTheArenaAndDrawFromIt(definition, 0.0f, 1.0f, 0.0f);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR))
<< "arena definition " << definition
<< " left a GL error behind (definition 0 creates the store, every later one "
"re-specifies it under the live VAO)";
}
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame(); // the swap that emits the window covering exactly the work above
const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog();
ASSERT_TRUE(window.found)
<< "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath()
<< ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting "
"here), so either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did not reach the "
"process, or no summary line was emitted at all because nothing reached "
"PipeStats::OnPresent.";
RecordProperty("stats_line", window.line.c_str());
const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr");
ASSERT_GE(roundtrips, 0)
<< "the summary line carries no mpr= field, so this build's PipeStats has no "
"map-persistent-roundtrips counter to read: "
<< window.line;
if (m_lane == kLaneOn) {
EXPECT_EQ(roundtrips, static_cast<long long>(kDefinitionsInTheWindow))
<< "with bits 7|8 SET the resource family is the path a store definition takes, so "
"each of the " << kDefinitionsInTheWindow
<< " definitions in this window is one map_persistent emission (mint or decline - "
"both need an answer from the resource owner, D-B2). "
<< (kDefinitionsInTheWindow * kDrawsPerDefinition)
<< " would mean an acquisition per draw, and 0 would mean the emission never "
"happened on the arm that is supposed to do it. It reported: "
<< window.line;
} else if (m_lane == kLaneOff) {
EXPECT_EQ(roundtrips, 0)
<< "with bits 7|8 CLEARED (MOBILEGL_PIPE_PUSH=0x7f, P2's default) the frontend "
"dispatch must fall through to the legacy BufferBackendOps arm and emit nothing "
"through the resource family, so mpr= must be zero. A non-zero count here is the "
"dead-switch reading: the bits are being ignored, both arms run the same code, "
"and every push-vs-pull number taken against this A/B is measuring one arm twice. "
"It reported: "
<< window.line;
} else {
FAIL() << "unknown " << kLaneMarker << " value '" << m_lane
<< "': the arms are on / off. Reading an unrecognised name as either would make "
"this lane assert the other arm's expectation while claiming to test this one.";
}
// ... and the picture is the same whichever arm ran. The arena is drawn with one solid
// colour, so both arms must read back exactly that.
EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset,
image.Height() - kInset, "green", 0.0,
"the arena draw [" + m_lane + "]"))
<< "the subsystem bits changed what is DRAWN, which is not an A/B - the handle path and "
"the legacy path must produce the same pixels from the same arena.";
}
} // namespace
} // namespace MGITest
@@ -19,11 +19,26 @@
//
// The assertion is deliberately on the WHOLE grown range, so a partial write names the byte the
// stale extent stopped at.
//
// P3a ADDS THE COST OF THAT REGROWTH (gate G10). ARCHITECTURE.md:474 prices a persistently mapped
// store at "one round trip per STORAGE DEFINITION, not one per store" - and, emphatically, not one
// per draw. `map-persistent-roundtrips` (`mpr=` in the summary line) counts every map_persistent
// EMISSION, mint or decline (D-B2), so the claim is directly countable: N definitions of an
// adopted store must publish exactly N, whatever the workload does between them. A regression that
// re-acquires per dispatch reports N x dispatches, which is the failure this case exists to name;
// a regression that stops emitting reports 0.
//
// The second case therefore respecifies a store LARGE ENOUGH TO BE ADOPTED
// (BufferObject::TryAdoptLargeStorage's 16 MiB threshold), several times, with several dispatches
// between the definitions, and reads the one window that covers exactly that workload. It skips -
// visibly, with the reason - on a tree where nothing emits the counter yet.
#include <cstdlib>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/PipeStatsWindow.h"
#include "../Harness/ScenarioFixture.h"
#ifdef GLAPI
@@ -50,6 +65,31 @@ void main() {
constexpr int kSmallElements = 6; // 24 bytes - the first iteration's size
constexpr int kLargeElements = 24; // 96 bytes - what the second iteration grows to
// The G10 case's sizes. Every one of them is past BufferObject::TryAdoptLargeStorage's
// 16 MiB threshold, because a store below it is never offered for adoption at all and the
// window would then be asserting that nothing happened. They GROW, which is the scenario's
// subject: each glBufferData is a new storage definition, so each is one acquisition.
constexpr int kAdoptedDefinitions = 3;
constexpr int kAdoptedBaseElements = 5 * 1024 * 1024; // 20 MiB of uint
constexpr int kAdoptedGrowthElements = 1024 * 1024; // + 4 MiB per definition
// Enough dispatches per definition that "one per definition" and "one per dispatch" are
// different numbers by a wide margin (3 vs 12), and few enough to stay cheap.
constexpr int kDispatchesPerDefinition = 4;
// Only the first elements are dispatched over: the point of the large store is the
// ADOPTION, not the compute cost.
constexpr int kDispatchedElements = 6;
// Set by the MapPersistentRoundtrips. ctest entry and by nothing else; a harness marker,
// never read by the library. Its absence means an ambient entry, where neither the stats
// channel nor a private log path is configured - and where the shared log makes a read
// race a neighbour's bring-up.
constexpr const char* kLaneMarker = "MGITEST_MPR_LANE";
bool BuildMarkerIsSet(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] == '1' && value[1] == '\0';
}
class StorageBufferRegrowScenario : public ScenarioTest {
protected:
void SetUp() override {
@@ -114,6 +154,56 @@ void main() {
return values;
}
// The Minecraft arena idiom, and the adoption point: a NULL-data definition of a
// store past the threshold. No host-side vector, so a 28 MiB definition costs
// nothing on this side of the API.
void DefineAdoptedStore(int elements) {
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
glBufferData(GL_SHADER_STORAGE_BUFFER,
static_cast<GLsizeiptr>(static_cast<GLsizeiptr>(elements) *
static_cast<GLsizeiptr>(sizeof(unsigned int))),
nullptr, GL_DYNAMIC_DRAW);
}
// GTEST_SKIP() returns from the function it is written in, so this cannot report
// through a return value; the caller pairs it with `if (IsSkipped()) return;`.
void SkipUnlessTheRoundtripCounterIsReadableHere() {
if (std::getenv(kLaneMarker) == nullptr) {
GTEST_SKIP() << "runs only in its own lane: the MapPersistentRoundtrips. ctest entry "
"sets MGITEST_MPR_LANE together with MOBILEGL_PIPE_PUSH's P3a mask, "
"MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private "
"MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient "
"entries, and the ambient log is shared, so a read here would race a "
"neighbour's bring-up.";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so "
"CallClass::MapPersistentRoundtrips does not exist (PipeStats.h "
"declares it inside the push guard, because growing the enum in a "
"pull build resizes the counter arrays and the name table - a G1 "
"break for a counter that could never leave zero) and the summary "
"line carries no mpr=. The entry is registered here anyway so that "
"`ctest -L integration-gpu` names the same tests in the pull build "
"and the push build (gate G2).";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) {
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
"MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing "
"emits map_persistent and mpr= is structurally zero. P3a package B "
"owns the client-side resource tracker; this entry arms itself when "
"it lands, whatever file that package puts the emitter in.";
return;
}
if (PipeStatsWindow::LibraryLogPath().empty()) {
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's "
"summary line is the only channel this module has for reading "
"PipeStats";
return;
}
}
unsigned int m_program = 0;
GLuint m_buffer = 0;
std::string m_buildLog;
@@ -153,4 +243,68 @@ void main() {
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
}
// G10. N storage definitions of an adopted store cost N map-persistent round trips - not one
// per draw, and not zero.
//
// ONE case in this lane, and that is a constraint rather than a preference: it READS the
// library log, the log is a per-lane resource (the library opens it fopen(path, "w"), so every
// process in a lane truncates it), and a second reading entry in the same lane would race this
// one under `ctest -j` with a failure that looks exactly like "the counter was never emitted".
// The plumbing is therefore asserted first, with its own message, inside this one process.
TEST_F(StorageBufferRegrowScenario, NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw) {
if (!Ready()) return;
SkipUnlessTheRoundtripCounterIsReadableHere();
if (IsSkipped()) return;
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, m_buffer);
Gl().EndFrame(); // close the setup window: everything below is one window
for (int definition = 0; definition < kAdoptedDefinitions; ++definition) {
DefineAdoptedStore(kAdoptedBaseElements + definition * kAdoptedGrowthElements);
ASSERT_EQ(FirstGLError(), 0u) << "definition " << definition << " of the adopted store failed";
for (int dispatch = 0; dispatch < kDispatchesPerDefinition; ++dispatch) {
glUseProgram(m_program);
glDispatchCompute(static_cast<GLuint>(kDispatchedElements), 1, 1);
glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
}
}
// The store is still the one the last definition made, and it still works: a counter
// assertion over a workload that silently stopped functioning would be measuring nothing.
std::vector<unsigned int> values(static_cast<std::size_t>(kDispatchedElements), 0xDEADBEEFu);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_buffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0,
static_cast<GLsizeiptr>(values.size() * sizeof(unsigned int)), values.data());
EXPECT_EQ(FirstGLError(), 0u);
for (int i = 0; i < kDispatchedElements; ++i) {
EXPECT_EQ(values[static_cast<std::size_t>(i)], static_cast<unsigned int>(i + 1))
<< "the adopted store's own dispatch did not write element " << i;
}
Gl().EndFrame(); // the swap that emits the window covering exactly the loop above
const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog();
ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath()
<< ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD "
"before getting here), so either MOBILEGL_PIPE_STATS / "
"MOBILEGL_PIPE_STATS_PERIOD did not reach the process or no summary "
"line was emitted at all because nothing reached PipeStats::OnPresent.";
RecordProperty("stats_line", window.line.c_str());
const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr");
ASSERT_GE(roundtrips, 0)
<< "the summary line carries no mpr= field, so this build's PipeStats has no "
"map-persistent-roundtrips counter to read: " << window.line;
EXPECT_EQ(roundtrips, static_cast<long long>(kAdoptedDefinitions))
<< "an adopted store costs ONE map_persistent per STORAGE DEFINITION "
"(ARCHITECTURE.md:474). This window defined the store " << kAdoptedDefinitions
<< " times and dispatched " << kDispatchesPerDefinition << " times against each of them, so "
<< kAdoptedDefinitions << " is the whole cost. "
<< (kAdoptedDefinitions * kDispatchesPerDefinition)
<< " would mean an acquisition per DRAW - the regression this counter exists to catch - and 0 "
"would mean nothing emitted map_persistent at all. It reported: "
<< window.line;
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, 0);
}
} // namespace MGITest
+111 -8
View File
@@ -34,11 +34,39 @@
/* dead: no backend reads it since D21; kept for inventory row 594 */ \
X(GetBoundTransformFeedbackName, SetStreamOutputTargets) \
X(GetBoundVertexArray, BindVertexElements) \
/* Polymorphic over BufferTarget: its rows split across set_vertex_buffers, */ \
/* set_index_buffer, set_indirect_buffers and set_shader_buffers when the */ \
/* inventory is re-vendored carrying the target argument (deferred out of P1: */ \
/* the extractor lives in MobileGL-CS). Named for the plan's explicit */ \
/* replacement of the DrawIndirect/Parameter pair. */ \
/* Polymorphic over BufferTarget, and P3a SPLITS it - not by re-vendoring the */ \
/* inventory (the extractor lives in MobileGL-CS and still does not carry the */ \
/* target argument), but by supplying the target from the EMISSION SITE, which */ \
/* knows it exactly. The split, target by target, in BufferTarget's OWN */ \
/* spelling (BufferObject.h:15-33) so the list cross-checks mechanically: */ \
/* Vertex, and the per-attribute buffer of a VAO -> set_vertex_buffers */ \
/* Index -> set_index_buffer */ \
/* DrawIndirect, Parameter -> set_indirect_buffers */ \
/* Uniform, ShaderStorage, AtomicCounter, */ \
/* TransformFeedback -> set_shader_buffers */ \
/* CopyRead, CopyWrite, PixelPack, PixelUnpack, */ \
/* Texture -> still pulled: the */ \
/* transfer and pixel-store targets have no call of their own yet; */ \
/* DispatchIndirect -> still pulled, and */ \
/* it is the one a reader most plausibly assumes set_indirect_buffers */ \
/* covers: BOTH backends read it (SyncBoundBuffer at every */ \
/* glDispatchComputeIndirect) and NO call carries it - MGPIndirectBuffers */ \
/* is the DrawIndirect + Parameter pair only - so the field stays the */ \
/* fill loop's for this target; */ \
/* Query -> still pulled: no */ \
/* backend reads it at all and no call names it. */ \
/* That is 15 of 15; a target missing from this list is a target a later */ \
/* phase would retire the pull for on the strength of a split that never */ \
/* covered it. */ \
/* THE ROW STAYS ONE ROW, and that is structural rather than a shortcut: this */ \
/* list IS the MGPipeInputField enum and the PipeInputs field set, and the */ \
/* field is ONE array (m_bufferBindingSlot[kBufferTargetCount]) that a second */ \
/* row of the same name could only duplicate. So the split lives here and in */ \
/* the emitters, and the row keeps naming set_indirect_buffers for the plan's */ \
/* explicit replacement of the DrawIndirect/Parameter pair. It is deliberately */ \
/* NOT in the EMITTED list below: seven targets above are still pulled, and a */ \
/* row there says "the whole field is supplied", which for this field would be */ \
/* the same half-truth GetPixelStoreParameters is kept out for. */ \
X(GetBufferBindingSlot, SetIndirectBuffers) \
X(GetBufferBindingPoint, SetShaderBuffers) \
X(GetBufferBindingPointCount, SetShaderBuffers) \
@@ -71,9 +99,10 @@
X(GetProgramForDispatch, SetDispatchProgram) \
X(GetProgramForDraw, SetDrawProgram) \
X(GetProgramObject, CreateShaderState) \
/* Not in ComputePipelineStateHash today even though Vulkan makes it pipeline */ \
/* state; recorded here so the G7 chunk table has to answer for it before it */ \
/* freezes (section 10.3-5). */ \
/* ANSWERED by P2: it is in the pipeline half. SetProvokingVertexMode calls */ \
/* BumpVersions(), and the chunk table's rule is exactly that, so it rides */ \
/* pipeline chunk P4 - a strict superset of what ComputePipelineStateHash used */ \
/* to hash (MGPipeRenderStateSpans.cpp records the provenance). */ \
X(GetProvokingVertexMode, CreateRenderState) \
X(GetRenderStateParameters, CreateRenderState) \
X(GetRenderStateParametersVersion, BindRenderState) \
@@ -128,4 +157,78 @@
X(handle-ify (wire handle), kStructuralHandle) \
X(Buffer ops delta, ResourceRespecify)
// X(Accessor, PipeCall) - the EMITTED list (P2 brief D5): which P2 call now SUPPLIES this
// PipeInputs field, so the per-verb residual fill loop no longer has to pull it out of
// GLContext. gen_pipe.py turns it into kMGPipeFieldEmittedBy[] (generated/PipeFilled.inc);
// a field with no row here keeps going through the fill loop, which is what makes the
// MOBILEGL_PIPE_PUSH bitmask a true per-subsystem A/B rather than an all-or-nothing switch.
//
// Every name must be an accessor in MGP_COVERAGE_ACCESSOR_LIST and every call must be a
// real call in PipeCalls.def; gen_pipe.py refuses anything else.
//
// The one row whose call differs from the accessor list's is GetPrimitiveRestartIndex:
// coverage maps it onto draw_vbo because that is where a backend reads it, but the VALUE
// travels in dynamic chunk D6, so set_dynamic_state is what supplies it.
//
// GetPixelStoreParameters is DELIBERATELY ABSENT, and the reason is the shape of the field
// rather than of the call. The field is PipeInputs::m_pixelStore[2] - pack AND unpack - and
// set_pixel_pack_state carries the PACK half only, deliberately and permanently (D10,
// ARCHITECTURE.md 4.6 D5: nothing on the far side of the boundary reads unpack state). A row
// here says "this field is supplied, the fill loop may skip it", which would be a half-truth:
// 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, so neither the poison nor the verify
// comparator could see it. Until the field is split, the whole of it keeps going through the
// fill loop and the pack half is simply written twice.
//
// P3a ADDS ONE ROW, GetBoundVertexArray -> BindVertexElements, and it is the vertex-input
// family's only candidate: GetBufferBindingSlot is polymorphic over a target set P3a covers
// only part of (see its comment above) and GetCurrentVertexAttribute has been here since P2.
// The row is INERT until the vertex-input subsystem is wired - MG_Impl/Pipe/PipeFill.cpp's
// kMGPipeWiredSubsystems does not carry that bit at the contract commit, because the emitters
// beside it are still stubs - which is exactly the guard that lets a row land before the call
// that carries it exists.
//
// A NOTE FOR THE COMMIT THAT WIRES IT, because it is not visible from this file: the field is
// a shared pointer to the frontend VAO, and the vertex-input calls supply the CONFIGURATION
// (the applier's MGPipeVertexElementsRecord), not the object. So the row is shape-only in the
// same sense GetCurrentVertexAttribute's is, and it stays so until the backend's twin
// resolution reads the applier's BoundVertexElements instead of the object - at which point
// PipeFill.cpp's EmittedCallSuppliesTheWholeField arm is where that is decided, deliberately
// rather than silently by this row's presence.
#define MGP_COVERAGE_EMITTED_LIST(X) \
X(GetBlendColor, SetDynamicState) \
X(GetBlendEquationIndexed, CreateRenderState) \
X(GetBlendFuncIndexed, CreateRenderState) \
X(GetBoundVertexArray, BindVertexElements) \
X(GetClampReadColor, SetDynamicState) \
X(GetClearColor, SetDynamicState) \
X(GetClearDepth, SetDynamicState) \
X(GetClearStencil, SetDynamicState) \
X(GetColorMaskIndexed, CreateRenderState) \
X(GetCullFaceMode, CreateRenderState) \
X(GetCurrentVertexAttribute, SetVertexAttribDefaults) \
X(GetDepthFunc, CreateRenderState) \
X(GetDepthMask, CreateRenderState) \
X(GetDepthRangeIndexed, SetDynamicState) \
X(GetLineWidth, SetDynamicState) \
X(GetLogicOp, CreateRenderState) \
X(GetMinSampleShadingValue, CreateRenderState) \
X(GetPatchDefaultInnerLevel, SetPatchState) \
X(GetPatchDefaultOuterLevel, SetPatchState) \
X(GetPatchVertices, SetPatchState) \
X(GetPipelineStateVersion, BindRenderState) \
X(GetPolygonModeFront, CreateRenderState) \
X(GetPolygonOffsetFactor, SetDynamicState) \
X(GetPolygonOffsetUnits, SetDynamicState) \
X(GetPrimitiveRestartIndex, SetDynamicState) \
X(GetProvokingVertexMode, CreateRenderState) \
X(GetRenderStateParameters, CreateRenderState) \
X(GetRenderStateParametersVersion, BindRenderState) \
X(GetScissorBox, SetDynamicState) \
X(GetStencilState, CreateRenderState) \
X(GetViewport, SetDynamicState) \
X(GetViewportIndexed, SetDynamicState) \
X(IsCapabilityEnabled, CreateRenderState) \
X(IsCapabilityEnabledIndexed, CreateRenderState)
// clang-format on
+334
View File
@@ -0,0 +1,334 @@
// MobileGL - MobileGL/MG_Pipe/DirtySurface.def
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The dirty-surface mapping (ARCHITECTURE.md 5.2 corollary 4, P2 brief D16).
//
// MGPipe replaces "the backend rediscovers what changed" with "the frontend says what
// changed", which only works if EVERY frontend mutation a backend can observe has an answer
// to "what publishes this". The failure mode is silent and one-directional: a mutation that
// forgets to publish renders stale, and no purity gate can see it.
//
// So the surface is enumerated MECHANICALLY. scripts/gen_pipe_dirty_surface.py scans
// MG_Impl/GLImpl AND MG_State/GLState for every pGLContext-> mutator call and every
// MGP_NOTE_MUTATION site, and, with --check, fails if a scanned mutator has no row here or a
// row here names a mutator the scan no longer finds. Both directions, so a deleted mutator
// cannot leave a stale row behind either.
//
// THE SECOND ROOT AND THE SECOND MECHANISM ARE P3a's, and they were not cosmetic: reading
// MG_State/GLState found BumpSamplingResolutionGeneration, a mutator with no row at all
// whose every caller lives in that directory, and reading MGP_NOTE_MUTATION brought
// NoteUnitTouched - which no `pGLContext->` pattern can see, because every texture and
// sampler bind routes through it rather than calling it through the context - inside the
// gate. A notice site carries a FIELD name, so the mutator a row names is the ENCLOSING
// function.
//
// ANSWERS. A row lists EVERY publisher that fires on EVERY path through that mutator,
// and only those; several are joined with '|'. A publisher that fires on some paths but
// not all must not appear, because a shutter built from this file would then UNDER-fire,
// and ARCHITECTURE.md 13.2 names under-firing as the dangerous direction. The one row that
// carries a bit which fires on only some paths says so in its answer - kPulledPartialShutter
// joined with that bit - because the alternative, dropping the bit, tells a reader of this
// file that a bit P2 already emits a call for has no shutter at all.
//
// "EVERY PATH" MEANS EVERY PATH THAT MUTATES. A setter that returns early because the value
// did not change publishes nothing and needs to publish nothing - there is no mutation to
// carry - so a redundant-write guard (SetColorMask's `if (changed) BumpVersions();`, the
// BitwiseEqual guards on the patch levels) does not make its publisher conditional in the
// sense this rule cares about. A publisher reached on only SOME of the paths that DO mutate
// - SetCapability's ClipDistance arms, SetStencilFunc's reference-only call - is the thing
// that must not be named.
//
// EVERY BIT ANSWER IN THIS FILE IS DERIVED AND CHECKED, in two families and one
// direction. The RenderState family (45 rows) is checked both ways against RenderState.cpp,
// below. Every other NEW_* answer is checked against the shutter Tracker.h builds for that
// bit: gen_pipe_dirty_surface.py resolves what the shutter READS and what each mutator
// transitively WRITES (through MGP_NOTE_AGGREGATE too, whose hop it reads out of
// MGPipeNoteAggregate's own switch, and through the function-like macros of MG_State, which
// it EXPANDS - sixteen of RenderState.cpp's writes exist only after the preprocessor has
// pasted them together) to the SAME two-level token, MEM:<member> plus FIELD:<member>.<leaf>,
// and fails a row that names a bit whose shutter its mutator moves on no path at all. That
// half is one-directional on purpose - "it does write something the shutter reads" cannot
// prove it does so on EVERY path - so it catches under-firing and not over-claiming.
//
// AN ABSENCE CLAIM IS ONLY WORTH THE READING BEHIND IT, and this gate learned that twice:
// its write analysis first recorded a write through a member's field as the field alone
// and never the member, so --check printed, as a fact about RenderState.cpp, that
// SetPixelStoreParam "writes nothing NEW_PIXEL_PACK's shutter reads" about a setter whose
// whole body is sixteen writes to exactly that member; then, once it read the member, it
// still could not see a write through a REFERENCE (SetBlendEquation's `for (auto&
// blendState : m_parameters.BlendStates)`) and said the same false thing about seven more
// setters - while its reader side resolved `render.PatchVertices` to the WHOLE of
// m_parameters, so every setter that touched any byte of it "supported" NEW_PATCH_STATE and
// a row saying glClearColor publishes the patch state was green. So now: a reference or
// pointer bound to a member-rooted lvalue is followed, and its writes are credited to the
// member and the field it was bound to; a write whose root the analysis cannot place (a
// reference parameter, a call result, a member without the m_ prefix, a token it could not
// expand) TAINTS the function, and every answer that depends on a tainted function is
// UNDECIDED - printed with its reason, never a verdict; a writer supports a bit only when
// the two sides share a member AND, both resolved to fields, their field sets intersect (a
// whole-member write or read is every field); a member in common with no field information
// on one side is COARSE, reported and never counted. --check counts only the supported
// answers as derived, prints the COARSE and UNDECIDED tallies, and FAILS on an UNDECIDED
// row unless MGP_DIRTY_SURFACE_UNDECIDED_LIST at the bottom of this file marks it - a
// mark that outlives its reason is a red gate too. What it still cannot claim: a shutter
// member written outside MG_State/GLState + MG_Impl/Pipe is undecided in the absence
// direction, a call is resolved by NAME to every body of that name, and a FIELD token is
// not scoped to a type - all three only widen what a mutator is credited with, and the
// second is also how a taint spreads.
//
// The prose answers (kImmediate, kExplicitDestroy, kUnpublishedDestroy, kNoBackendRead,
// kPulledEveryVerb, kPulledPartialShutter, kReverseChannel) are statements no derivation
// checks - except the bits a kPulledPartialShutter row names, which are checked like any
// other bit answer. --check prints how many rows carry a prose answer, so "all mapped" can
// never be read as "all verified".
//
// For the RenderState family that answer is not a matter of taste and it is CHECKED
// rather than asserted: scripts/gen_pipe_dirty_surface.py reads RenderState.cpp and
// derives, per setter, which of NEW_RENDER_STATE / NEW_PIPELINE_STATE moves on every
// path - BumpVersions() moves both, a bare ++m_version moves only NEW_RENDER_STATE, and
// a setter with both kinds of path therefore always-fires only NEW_RENDER_STATE - and
// --check fails when a row disagrees, in either direction. That check exists because
// this file got exactly two rows wrong: SetCapability, whose ClipDistance0..7 arms move
// only m_version, and SetStencilFunc, whose pipeline bump is conditional on Func moving.
// Both named NEW_PIPELINE_STATE, which does not fire for glEnable(GL_CLIP_DISTANCE0) or
// for a reference-only glStencilFunc.
//
// NEW_* a MGPipeDirty bit (MG_Impl/Pipe/Tracker.h). The tracker's shutter for
// that bit moves when this mutator runs, so the next verb publishes it.
// kImmediate the mutating function also reaches the backend in the same body, so the
// mutation is published inline and needs no shutter at all.
// kReverseChannel not state: a write INTO the frontend from the backend's side.
// kNoBackendRead no backend read point observes this state at all.
// kExplicitDestroy published by the delete_* / resource_destroy call the Track H slice
// emits when the object's last reference drops - an object's DEATH,
// which no generation shutters because there is no longer an object
// to carry one. Only for a kind that HAS an identity on the wire to
// destroy: the resources and CSOs of PipeCalls.def, which is what P2
// brief D13 scopes Espryt 0b's explicit destroy to.
// kUnpublishedDestroy
// the same event for a kind NOTHING publishes: a program, a program
// pipeline and a shader have no per-object handle on the wire at all
// in P2 - resource_destroy and the delete_* family name resources and
// CSOs - so their DirectGLES twins are still reclaimed by the
// backend's own registry teardown and no frontend call says they
// died. Recorded as a hole rather than dressed up as a mechanism that
// exists; naming kExplicitDestroy here would be the same defect the
// RenderState derivation above exists to stop, one class down in
// stakes. (D13's prose says 'six kinds' while the Core.cpp ranges it
// cites also cover MarkProgram/MarkShaderForDeletion; the tree
// decides, and the tree has no wire object for those three.)
// kPulledEveryVerb no shutter exists at all - no MGPipeDirty bit moves on any path through
// this mutator - and none is needed yet: the PipeInputs field it writes is
// in its verb class's may-read mask, so the residual fill copies it at
// EVERY verb of that class. A shutter here is a P3/P4 optimisation, not a
// correctness gap.
// kPulledPartialShutter
// the same pull, but a bit DOES move - on some of the paths that mutate,
// not all of them - so this row must never be read as "no shutter exists".
// The bits that move are named after the '|', which is the one place this
// file joins a prose answer with a bit, and the reason is exactly that a
// P3a shutter builder has to be able to tell "no bit covers this" from "a
// bit covers half of it". The named bits are checked the same way every
// other bit answer is - a dead one is a red gate - but they are NOT a
// licence to narrow: what holds on every mutating path is the pull.
// Which rows need this answer is a human judgement and stays one: the
// derivation's "it does move that shutter" direction over-approximates
// (a call name resolves to every body of that name, a write inside an
// `if` counts), so it can refute a named bit but cannot find the rows
// that should have named one.
//
// KNOWN BLIND SPOTS OF THE SCANNER, recorded here rather than left implicit
// (gen_pipe_dirty_surface.py's own notes plus its scan roots):
// 1. it matches braced function bodies textually, so a mutator inside a LAMBDA is
// attributed to the enclosing function;
// 2. a mutation published through a HELPER the entry point calls reads as deferred here.
// 3. CLOSED AT P3a. The scan root was MG_Impl/GLImpl only, so the four MGP_NOTE_MUTATION
// sites in MG_State/GLState/TextureState/TextureState.h were outside it entirely. The
// root is now both directories and the notice is a recognised publish mechanism; what
// that found is the two rows marked "P3a" below.
// The gate is therefore a COMPLETENESS gate over what the scanner does see. The semantic
// proof stays the MOBILEGL_PIPE_VERIFY lane, which is blind to none of them.
//
// clang-format off
// X(Mutator, Answer)
#define MGP_DIRTY_SURFACE_LIST(X) \
/* ---- the reverse channel: 836 of the 926 calls, 90% of the surface ---- */ \
X(RecordError, kReverseChannel) \
/* ---- immediate publish points: the same body reaches the backend ---- */ \
X(SetActiveTextureUnit, kImmediate) \
X(BeginTransformFeedback, kImmediate) \
X(EndTransformFeedback, kImmediate) \
X(SetTransformFeedbackPaused, kImmediate) \
X(MarkTransformFeedbackObjectForDeletion, kImmediate) \
/* ---- the render state. Derived from RenderState.cpp and gated by --check: */ \
/* a setter that calls BumpVersions() on every path publishes BOTH counters; */ \
/* one that also has a bare ++m_version path publishes only NEW_RENDER_STATE. */ \
X(SetBlendEquation, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetBlendEquationIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetBlendFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetBlendFuncIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
/* SetCapability's ClipDistance0..7 arms write ClipDistanceEnabledMask (dynamic */ \
/* chunk D7) and deliberately do NOT BumpVersions, so NEW_PIPELINE_STATE does */ \
/* not fire at all for glEnable(GL_CLIP_DISTANCE0): set_dynamic_state publishes */ \
/* it, and NEW_RENDER_STATE is the only answer that holds on every arm. */ \
X(SetCapability, NEW_RENDER_STATE) \
X(SetCapabilityIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetColorMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetColorMaskIndexed, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetCullFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetDepthFunc, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetDepthMask, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetFrontFaceMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetLogicOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetMinSampleShadingValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetPolygonMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetProvokingVertexMode, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetSampleCoverage, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetSampleMaskValue, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
/* SetStencilFunc writes Func (pipeline chunk P2/P3) AND Ref/ValueMask (dynamic */ \
/* D3/D4), and ++m_pipelineStateVersion is CONDITIONAL on Func moving - which is */ \
/* what keeps a glStencilFunc that moves only the reference from evicting a */ \
/* cached pipeline, and is why only NEW_RENDER_STATE fires on every call. */ \
/* SetStencilOp is wholly pipeline, SetStencilMask wholly dynamic. */ \
X(SetStencilFunc, NEW_RENDER_STATE) \
X(SetStencilOp, NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetStencilMask, NEW_RENDER_STATE) \
X(SetBlendColor, NEW_RENDER_STATE) \
X(SetClampReadColor, NEW_RENDER_STATE) \
X(SetClearColor, NEW_RENDER_STATE) \
X(SetClearDepth, NEW_RENDER_STATE) \
X(SetClearStencil, NEW_RENDER_STATE) \
X(SetClipControl, NEW_RENDER_STATE) \
X(SetDepthRange, NEW_RENDER_STATE) \
X(SetDepthRangeIndexed, NEW_RENDER_STATE) \
X(SetHint, NEW_RENDER_STATE) \
X(SetLineWidth, NEW_RENDER_STATE) \
X(SetPointFadeThresholdSize, NEW_RENDER_STATE) \
X(SetPointSize, NEW_RENDER_STATE) \
X(SetPointSpriteCoordOrigin, NEW_RENDER_STATE) \
X(SetPolygonOffset, NEW_RENDER_STATE) \
X(SetPolygonOffsetClamped, NEW_RENDER_STATE) \
X(SetPrimitiveRestartIndex, NEW_RENDER_STATE) \
X(SetScissorBox, NEW_RENDER_STATE) \
X(SetScissorBoxIndexed, NEW_RENDER_STATE) \
X(SetViewport, NEW_RENDER_STATE) \
X(SetViewportIndexed, NEW_RENDER_STATE) \
/* ---- the other value-class bits ---- */ \
/* kPulledPartialShutter, NOT kPulledEveryVerb, and NOT a bare NEW_PIXEL_PACK: */ \
/* RenderState::SetPixelStoreParam writes BOTH halves - eight Pack arms and eight */ \
/* Unpack arms - while the tracker's bit 2 is a byte compare of the PACK half alone */ \
/* (Tracker.h), because set_pixel_pack_state deliberately has no unpack counterpart */ \
/* (ARCHITECTURE.md 4.6). So glPixelStorei(GL_PACK_ALIGNMENT, 8) DOES move bit 2 and */ \
/* glPixelStorei(GL_UNPACK_ALIGNMENT, 8) moves nothing at all, and a shutter narrowed */ \
/* to bit 2 would under-fire for eight of the sixteen arms. What is true on every path */ \
/* is the pull: GetPixelStoreParameters is one of the two Coverage.def rows an emitted */ \
/* call does not supply completely (PipeFill.cpp), so the residual fill copies both */ \
/* halves at every verb of the class. The bit is named anyway because P2 already EMITS */ \
/* set_pixel_pack_state off it: a row that said "no shutter exists" about the only */ \
/* mutator behind a shipped call would be a false answer to the one question D16 hands */ \
/* P3a. Splitting this setter into a pack half and an unpack half is what would let the */ \
/* pack half answer NEW_PIXEL_PACK outright; that is P3's move, not P2's. */ \
X(SetPixelStoreParam, kPulledPartialShutter|NEW_PIXEL_PACK) \
X(SetPatchDefaultInnerLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetPatchDefaultOuterLevel, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
/* Also an immediate publish point, but it has a real bit and the bit is */ \
/* the more useful answer: set_patch_state carries it whatever the caller */ \
/* does next. */ \
X(SetPatchVertices, NEW_PATCH_STATE|NEW_RENDER_STATE|NEW_PIPELINE_STATE) \
X(SetCurrentVertexAttributeFloat, NEW_VERTEX_ATTRIB_DEFAULTS) \
X(SetCurrentVertexAttributeInt, NEW_VERTEX_ATTRIB_DEFAULTS) \
X(SetCurrentVertexAttributeUint, NEW_VERTEX_ATTRIB_DEFAULTS) \
/* ---- object class ---- */ \
X(BumpTextureBindGeneration, NEW_SAMPLER_VIEWS) \
/* P3a, FOUND BY THE WIDENED SCAN ROOT and not by anything before it: every caller of */ \
/* this one is inside MG_State/GLState (SamplerObject::BumpVersion for any sampler */ \
/* parameter, TextureObjectBase's shape bump), which the scan did not read, so it had */ \
/* no row at all while its sibling above did. NEW_SAMPLERS is the bit whose shutter */ \
/* mixes the texture-params aggregate with exactly this generation, and the bump is */ \
/* unconditional on every path that reaches it - the early-outs are in the setters */ \
/* above it, which is the redundant-write guard this file's rule explicitly allows. */ \
X(BumpSamplingResolutionGeneration, NEW_SAMPLERS) \
/* P3a, and the reason the scan now reads MGP_NOTE_MUTATION as a publish mechanism of */ \
/* its own: this function is not a pGLContext-> mutator at all - every texture and */ \
/* sampler bind entry point routes THROUGH it - and it moves two pushed PipeInputs */ \
/* fields with two different answers. */ \
/* GetTextureBindGeneration moves only on the bindingChanged arm, so NEW_SAMPLER_ */ \
/* VIEWS fires on SOME of the paths that mutate and not all: a redundant re-bind */ \
/* of the object a unit already holds advances the high-water mark alone. That is */ \
/* precisely what kPulledPartialShutter is for. */ \
/* GetMaxTouchedTextureUnit has no shutter at all; it is in its verb class's may-read */ \
/* mask and the residual fill copies it at every verb. */ \
/* Both are ALSO published inline by MGP_NOTE_MUTATION when the write happens inside a */ \
/* verb already in flight - a backend binding its own synthesised fallback texture - */ \
/* which is the window no shutter and no pull can cover, and the reason those sites */ \
/* had to come inside this gate rather than stay a recorded blind spot. */ \
X(NoteUnitTouched, kPulledPartialShutter|NEW_SAMPLER_VIEWS) \
/* NOT NEW_SO_TARGETS, and this one was false on EVERY path: GLContext::SetNamed */ \
/* TransformFeedbackBinding either binds a BufferState binding point (index == the */ \
/* bound XFB object) or writes a saved-bindings entry, and NEW_SO_TARGETS mixes the */ \
/* buffer-CONTENT aggregate with the transform-feedback generation - the first moves */ \
/* only at BufferObject.cpp's content sites, the second only in BeginTransformFeedback. */ \
/* A binding moves neither. It reaches the backend the same way every other buffer */ \
/* binding point does, through GetBufferBindingPoint in the verb class's may-read mask, */ \
/* so the honest answer is the pull. Narrowing it is P3b's, when it takes the subsystem */ \
/* over and the binding points get a generation of their own. */ \
X(SetNamedTransformFeedbackBinding, kPulledEveryVerb) \
/* ---- an object's death: no generation, because there is no longer an object */ \
/* to carry one. Espryt 0b's delete_* / resource_destroy publishes the kinds */ \
/* that have a handle on the wire; programs, program pipelines and shaders have */ \
/* none in P2, so nothing publishes theirs - kUnpublishedDestroy, a known hole. */ \
/* TWO OF THESE ROWS STOPPED BEING ASPIRATIONAL AT P3a, and the call that makes */ \
/* each true is named rather than implied: */ \
/* MarkBufferObjectForDeletion -> resource_destroy (PipeCalls.def), emitted */ \
/* from ~BufferObject the moment the last reference drops - which is the */ \
/* glDelete* that only marks the name, followed by whatever unbind actually */ \
/* releases it - and followed IN THAT ORDER by the client freeing the slot. */ \
/* MarkVertexArrayForDeletion -> delete_vertex_elements, published through the */ \
/* death notice ~VertexArrayObject already raises for the VertexElementsCso */ \
/* kind; the CSO handle is minted per frontend VAO off its lifetime id. */ \
/* THE PUBLISHER IS THE BACKEND'S (Managers.cpp's OnFrontendStateObject- */ \
/* Destroyed consumer, package espryt), not the client's: the client mints */ \
/* the CSO handle and emits create/bind, and the free rides with that */ \
/* consumer. Until it lands the row states the design, not the tree. */ \
X(MarkBufferObjectForDeletion, kExplicitDestroy) \
X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \
X(MarkProgramForDeletion, kUnpublishedDestroy) \
X(MarkProgramPipelineForDeletion, kUnpublishedDestroy) \
X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \
X(MarkSamplerObjectForDeletion, kExplicitDestroy) \
X(MarkShaderForDeletion, kUnpublishedDestroy) \
X(MarkTextureObjectForDeletion, kExplicitDestroy) \
X(MarkVertexArrayForDeletion, kExplicitDestroy) \
/* ---- no backend read point observes these at all ---- */ \
/* GL_ANY_SAMPLES_PASSED conditional rendering is resolved wholly in the */ \
/* frontend: IsConditionalRenderActive / GetConditionalRenderQuery have no */ \
/* reader under MG_Backend and no Coverage.def row. */ \
X(BeginConditionalRender, kNoBackendRead) \
X(EndConditionalRender, kNoBackendRead) \
/* ---- pulled at every verb of the class, so the next verb publishes them */ \
/* unconditionally. The transform-feedback accounting counters reach the */ \
/* backend through GetTransformFeedbackCapturedVertices and friends, which */ \
/* are in the kDraw and kXfbSpan may-read masks. */ \
X(AddTransformFeedbackAccountedCaptureDraw, kPulledEveryVerb) \
X(AddTransformFeedbackCapturedVertices, kPulledEveryVerb) \
X(AddTransformFeedbackGeometryCaptureDraw, kPulledEveryVerb) \
X(AddTransformFeedbackInputPrimitives, kPulledEveryVerb) \
X(AddTransformFeedbackPausedPrimitives, kPulledEveryVerb) \
X(AddTransformFeedbackPrimitives, kPulledEveryVerb)
// X(Mutator, Bit) - the (row, bit) pairs above whose derivation is KNOWN to come out
// UNDECIDED, each with the reason --check prints for it. Every bit answer NOT listed here
// is marked derived: --check fails when the derivation cannot decide it, and fails again
// when a mark here names a pair the derivation now decides, so this list can neither hide a
// row nor outlive its reason. Empty today: every bit answer above is supported at field
// level. The ten mutators that reach a tainted body (--check prints the count) all carry a
// prose answer, which no derivation checks.
#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X)
// clang-format on
+44
View File
@@ -26,6 +26,50 @@
// Fatal{UnmigratedPipeInput, "Field@Verb"} found there is fixed by adding the (class, field)
// row, never by marking the field sticky.
//
// ---------------------------------------------------------------------------------------
// THE VERDICT ON THE EIGHT STATICALLY OVER-APPROXIMATED ROWS (P2 brief C.1, MEASUREMENTS.md
// section 4). Every one of them is KEPT, and the reason is the same in all three groups: the
// row is not a guess, it names a concrete backend path, and the only evidence that could
// retire it is DYNAMIC - a corpus that never reaches the path proves nothing, because a row
// removed on that basis turns a rare path into Fatal{UnmigratedPipeInput} in a shipped build.
//
// kReadback + IsTransformFeedbackActive / IsTransformFeedbackPaused
// KEPT. The depth/stencil read emulation draws (ScopedEmulationDrawState, DirectGLES.cpp)
// and pauses an active capture around its own draw, so a glReadPixels of a depth or
// stencil attachment reads the transform-feedback state exactly as a draw does. Reached
// only when the emulation is armed, which is a driver-shaped decision, so no desktop
// corpus can decide it.
//
// kTextureOp + IsCapabilityEnabled, kDispatch + IsCapabilityEnabled
// KEPT. Magma's GenerateMipmap materialises a texture's queued clear before it blits and
// PrepareStorageImageTextures does the same for every storage image a dispatch writes;
// both go through VkClearManager::PreCompensateSrgbClearColor, which reads
// GL_FRAMEBUFFER_SRGB. The P2 contract gave that capability real storage for the first
// time, so this row went from reading a compile-time constant to reading real state -
// which is the opposite of a row that could be dropped.
//
// kBlitOrCopy / kTextureOp + the shader blit's viewport and vertex/buffer bindings
// (GetViewportIndexed, GetDepthRangeIndexed, GetProvokingVertexMode, GetBufferBindingPoint)
// KEPT. TryBlitToDefaultFramebufferWithShader is a real draw of a backend-owned helper
// program: ApplyGLViewportState -> ComputeGLViewport reads viewport 0 and its depth range,
// GetOrCreateBlitPipeline -> SelectProvokingVertexMode reads the provoking vertex, and
// BindProgramUniformBuffers' block resolvers read the frontend binding points. It is taken
// when a blit's destination is the default framebuffer and the driver cannot do it
// natively - again a driver-shaped decision.
//
// What WOULD retire a row: the poison build already answers "was this field read at this
// verb" exactly (MOBILEGL_PIPE_POISON_OMIT withholds one field's stamp for one verb and a
// read of it aborts naming the pair). Turning that into a retirement gate means running the
// omission across the full CTS caselist on both devices, not the desktop corpus.
//
// P3a STATUS: still not done, and deliberately not done here. P3a is one of the five
// architecture boundaries that owe a full gl44to46 caselist run on both devices, so the
// omission sweep rides that run rather than duplicating it - the verdict lands with the
// caselist result at the phase exit, off the critical path, and every row above stays in
// place until it does. A row retired on desktop evidence would be retired on evidence that
// cannot support it, which is the reason this item exists rather than a scheduling excuse.
// ---------------------------------------------------------------------------------------
//
// gen_pipe.py's block regexes end at a blank line: keep the empty line after each macro.
//
// clang-format off
+38 -4
View File
@@ -55,10 +55,44 @@ namespace MobileGL::MG_Pipe {
};
// The pipeline/dynamic split of RenderStateParameters, defined exactly once (section
// 4.5.2). Generated by G7 from the field list ComputePipelineStateHash already hashes;
// MGPipeRenderStateSpans.cpp and the setter-consistency test land with P2, which is
// when the chunk table can be filled with real offsets.
struct MGPipeRenderStateSpans;
// 4.5.2): MG_Pipe/MGPipeRenderStateSpans.{h,cpp}, which landed with P2 and computes
// every chunk boundary with offsetof. Include that header to use it; what stays here
// is the generated member list at the bottom of this file, which is what the chunk
// table was derived from.
// ---- MOBILEGL_PIPE_PUSH's runtime bitmask (Config.h Features.PipePush) ----
//
// One bit per SUBSYSTEM, so an A/B is per subsystem rather than all-or-nothing, and
// bit 63 for the one BEHAVIOUR the design has to be measured against. Bits are
// allocated in ROADMAP order and never reused: an operator's recorded 0x7f has to keep
// meaning what it meant.
//
// A clear subsystem bit means "keep pulling", which after P2 is only a valid control
// while MOBILEGL_PIPE_LEGACY_MEMOS compiles the pre-handle arm beside it.
inline constexpr Uint64 kMGPipeSubsystemRenderState = 1ull << 0;
inline constexpr Uint64 kMGPipeSubsystemPixelPack = 1ull << 1;
inline constexpr Uint64 kMGPipeSubsystemPatchState = 1ull << 2;
inline constexpr Uint64 kMGPipeSubsystemVertexAttribDefaults = 1ull << 3;
inline constexpr Uint64 kMGPipeSubsystemResidualValues = 1ull << 4;
inline constexpr Uint64 kMGPipeSubsystemEsprytSlots = 1ull << 5; // Track H, Espryt 0b
inline constexpr Uint64 kMGPipeSubsystemMagmaVertexInput = 1ull << 6; // Track H, Magma subsystem 4
// P3a's two. Resources is the seven BufferBackendOps hooks turned into the handle-shaped
// resource_* family; VertexInput is vertex elements, vertex buffers and the index buffer.
// They are separate bits because they are separate A/Bs: a buffer path that regressed and
// a vertex path that regressed are different findings, and clearing one must not disarm
// the other.
inline constexpr Uint64 kMGPipeSubsystemResources = 1ull << 7;
inline constexpr Uint64 kMGPipeSubsystemVertexInput = 1ull << 8;
// bits 9..62 reserved for the later phases, allocated in ROADMAP order.
// NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of CSOs, so
// every pipeline-version change mints a fresh CSO and the map is never probed. This is
// the negative control the whole CSO design is measured against (ROADMAP.md P2).
inline constexpr Uint64 kMGPipeBehaviourNoCsoContentAddressing = 1ull << 63;
// The default of a push build with the knob unset (ConfigLoader.cpp). Each phase's
// constant STAYS, because it is the A/B control for the phase after it: P3a's
// "everything P2 had and nothing of mine" arm is spelled MOBILEGL_PIPE_PUSH=0x7f.
inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP2 = 0x7full; // bits 0..6
inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP3a = 0x1ffull; // bits 0..8
// The catalogue itself. Only macros, so it is safe to expand inside the namespace, and
// consumers (the unit test, later the transport) get MGP_CALL_LIST from this header.
+196
View File
@@ -0,0 +1,196 @@
// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The definitions behind MGPipeRenderStateSpans.h and behind the two arrays
// generated/PipeSpanTable.inc has declared since P0. Compiled ONLY under
// MOBILEGL_PIPE_PUSH (CMakeLists.txt appends it to SOURCE_FILES there), which is how the
// pull build gains no symbol from the split - a declaration emits nothing.
//
// PROVENANCE OF THE PIPELINE HALF. It began as the enumeration
// VulkanRenderer::ComputePipelineStateHash carried above itself, which was the contract
// that function had without being able to say so; it moves here because this file is now
// that contract. Verbatim, from VulkanRenderer.cpp at feat/disaggregated@48268068:
//
// Value hash over every fixed-function GL state the pipeline payload reads that
// the memo key's other fields (mode, program hash, vertex-input hash, render-pass
// hash, transform flags) do not already pin down. Enumerated against the payload
// build in GetOrCreatePipeline - any new GL-state read there must be added here:
// - capability bits: CullFace, DepthTest, PolygonOffsetFill (mode gating rides
// the memo's mode key), RasterizerDiscard, ColorLogicOp, StencilTest,
// PrimitiveRestart(+FixedIndex), SampleShading, SampleMask, plus the depth write mask
// - patch vertices, polygon mode, cull face mode, depth func, logic op,
// min sample shading, the glSampleMaski word
// - front/back stencil ops + compare funcs (ref/mask are dynamic state)
// - per draw buffer up to the render pass's colour span: indexed blend enable,
// blend factors/equations, indexed colour write mask (broadcast from index 0
// when the device lacks independentBlend - the same read the payload does)
// FBO-derived payload inputs (attachment presence/formats/draw-buffer gating) are
// pinned by the render-pass hash key, exactly as the version-keyed memo relied on.
//
// P2's pipeline half is a strict SUPERSET of that list. It adds SampleCoverageValue,
// SampleCoverageInvert, FrontFaceModeSetting, ProvokingVertexModeSetting,
// ScissorTestEnabledMask, PolygonModeBack, the eleven capability bools the hash never read
// (DebugOutput, DebugOutputSynchronous, Dither, LineSmooth, PolygonOffsetLine,
// PolygonOffsetPoint, PolygonSmooth, SampleAlphaToCoverage, SampleAlphaToOne, SampleCoverage,
// ProgramPointSize) and the three capabilities P2 gave storage to (FramebufferSrgb,
// DepthClamp, TextureCubeMapSeamless). All of them are written by a setter that calls
// BumpVersions(), so under the header's rule they are pipeline. The alternative - demoting
// those setters to ++m_version - would change MG_State semantics in the PULL build for the
// sake of the push path. Growing the subset costs nothing measurable: the hash runs only
// when m_pipelineStateVersion moves, which is exactly when Magma recomputed
// ComputePipelineStateHash before.
//
// The render-pass facts are deliberately NOT here. ComputePipelineStateHash's signature is
// (colorAttachmentCount, rasterizationSamples) and it folds ResolveEffectiveSampleMask, so
// it was never a pure function of RenderStateParameters; a CSO handle cannot replace it on
// its own and Magma keeps renderPassHash as a separate memo-key component.
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
#include <cstring>
namespace MobileGL::MG_Pipe {
namespace {
// Half-local chunk index -> global chunk index. The halves alternate, so this is
// arithmetic rather than a table.
constexpr SizeT GlobalPipelineChunk(SizeT halfIndex) { return halfIndex * 2 + 1; }
constexpr SizeT GlobalDynamicChunk(SizeT halfIndex) { return halfIndex * 2; }
const Uint8* BytesOf(const RenderStateParameters& params) {
return reinterpret_cast<const Uint8*>(&params);
}
Uint8* BytesOf(RenderStateParameters& params) { return reinterpret_cast<Uint8*>(&params); }
SizeT BlobBytes(Uint32 chunkMask, SizeT halfCount, SizeT (*toGlobal)(SizeT)) {
SizeT total = 0;
for (SizeT i = 0; i < halfCount; ++i) {
if ((chunkMask & (1u << i)) == 0) continue;
total += MGPipeRenderStateChunkAt(toGlobal(i)).Length;
}
return total;
}
void Gather(const RenderStateParameters& params, Uint32 chunkMask, void* dst, SizeT halfCount,
SizeT (*toGlobal)(SizeT)) {
Uint8* out = static_cast<Uint8*>(dst);
const Uint8* src = BytesOf(params);
for (SizeT i = 0; i < halfCount; ++i) {
if ((chunkMask & (1u << i)) == 0) continue;
const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i));
std::memcpy(out, src + chunk.Offset, chunk.Length);
out += chunk.Length;
}
}
void Scatter(const void* src, Uint32 chunkMask, RenderStateParameters& dst, SizeT halfCount,
SizeT (*toGlobal)(SizeT)) {
const Uint8* in = static_cast<const Uint8*>(src);
Uint8* out = BytesOf(dst);
for (SizeT i = 0; i < halfCount; ++i) {
if ((chunkMask & (1u << i)) == 0) continue;
const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i));
std::memcpy(out + chunk.Offset, in, chunk.Length);
in += chunk.Length;
}
}
Uint32 ChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b,
SizeT halfCount, SizeT (*toGlobal)(SizeT)) {
const Uint8* left = BytesOf(a);
const Uint8* right = BytesOf(b);
Uint32 mask = 0;
for (SizeT i = 0; i < halfCount; ++i) {
const MGPStateChunk chunk = MGPipeRenderStateChunkAt(toGlobal(i));
if (std::memcmp(left + chunk.Offset, right + chunk.Offset, chunk.Length) != 0) {
mask |= 1u << i;
}
}
return mask;
}
constexpr Uint32 AllChunks(SizeT halfCount) {
return halfCount >= 32 ? ~Uint32{0} : static_cast<Uint32>((Uint64{1} << halfCount) - 1);
}
} // namespace
// The two arrays generated/PipeSpanTable.inc declares. Every entry is
// MGPipeRenderStateChunkAt(), so a boundary can only be written once.
const MGPStateChunk kMGPipePipelineChunks[kMGPipePipelineChunkCount] = {
MGPipeRenderStateChunkAt(GlobalPipelineChunk(0)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(1)),
MGPipeRenderStateChunkAt(GlobalPipelineChunk(2)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(3)),
MGPipeRenderStateChunkAt(GlobalPipelineChunk(4)), MGPipeRenderStateChunkAt(GlobalPipelineChunk(5)),
MGPipeRenderStateChunkAt(GlobalPipelineChunk(6)),
};
static_assert(sizeof(kMGPipePipelineChunks) / sizeof(kMGPipePipelineChunks[0]) == kMGPipePipelineChunkCount,
"kMGPipePipelineChunks lost an entry");
const MGPStateChunk kMGPipeDynamicChunks[kMGPipeDynamicChunkCount] = {
MGPipeRenderStateChunkAt(GlobalDynamicChunk(0)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(1)),
MGPipeRenderStateChunkAt(GlobalDynamicChunk(2)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(3)),
MGPipeRenderStateChunkAt(GlobalDynamicChunk(4)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(5)),
MGPipeRenderStateChunkAt(GlobalDynamicChunk(6)), MGPipeRenderStateChunkAt(GlobalDynamicChunk(7)),
};
static_assert(sizeof(kMGPipeDynamicChunks) / sizeof(kMGPipeDynamicChunks[0]) == kMGPipeDynamicChunkCount,
"kMGPipeDynamicChunks lost an entry");
void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst) {
Gather(params, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount,
GlobalPipelineChunk);
}
void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst) {
Scatter(src, AllChunks(kMGPipePipelineChunkCount), dst, kMGPipePipelineChunkCount,
GlobalPipelineChunk);
}
SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask) {
return BlobBytes(chunkMask, kMGPipePipelineChunkCount, GlobalPipelineChunk);
}
void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) {
Gather(params, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk);
}
void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) {
Scatter(src, chunkMask, dst, kMGPipePipelineChunkCount, GlobalPipelineChunk);
}
SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask) {
return BlobBytes(chunkMask, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
}
void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst) {
Gather(params, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
}
void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst) {
Scatter(src, chunkMask, dst, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
}
Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) {
return ChunksThatMoved(a, b, kMGPipeDynamicChunkCount, GlobalDynamicChunk);
}
Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b) {
return ChunksThatMoved(a, b, kMGPipePipelineChunkCount, GlobalPipelineChunk);
}
Uint64 MGPipeHashPipelineBytes(const void* bytes) {
return static_cast<Uint64>(
XXH64(bytes, kMGPipePipelineChunkBytes, kMGPipeRenderStateChunkTableSeed));
}
Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params) {
// 396 bytes on the stack. A streaming XXH64_state_t would allocate; gathering first
// is also what CsoCache wants, because the same bytes are what a hash hit memcmps
// against before the handle is reused.
Uint8 gathered[kMGPipePipelineChunkBytes];
MGPipeGatherPipelineBytes(params, gathered);
return MGPipeHashPipelineBytes(gathered);
}
} // namespace MobileGL::MG_Pipe
+277
View File
@@ -0,0 +1,277 @@
// MobileGL - MobileGL/MG_Pipe/MGPipeRenderStateSpans.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include "MGPipeTypes.h"
#include "MGPipeValueTypes.h"
// G7: the pipeline/dynamic split of RenderStateParameters, written in EXACTLY ONE PLACE
// (ARCHITECTURE.md 5.3, D-B1).
//
// The rule that decides the split, and it is the only rule:
//
// A byte of RenderStateParameters is in the PIPELINE half if and only if some public
// RenderState setter that calls BumpVersions() writes it. Every other byte is in the
// DYNAMIC half. There is no third set.
//
// That makes the G7 invariant - the pipeline-subset hash moves IF AND ONLY IF
// m_pipelineStateVersion moves - true by CONSTRUCTION rather than by inspection, and it is
// what MG_Test/Pipe/RenderStateSpansTest.cpp walks every setter to confirm.
//
// The chunks alternate: chunk 0 is dynamic, chunk 1 is pipeline, and so on, so the whole
// table is 16 BOUNDARIES rather than 15 hand-written ranges. Every boundary is an offsetof
// or a sizeof - never a literal - because a python guess at a layout it cannot see is
// exactly the drift the setter-consistency test exists to catch. 8 dynamic chunks + 7
// pipeline chunks = 15, and both counts fit the Uint32 ChunkMask of MGPRenderStateDesc and
// MGPDynamicState with room to spare.
//
// Note the two splits are ORTHOGONAL and coexist (ARCHITECTURE.md 5.3): DirectGLES'
// head [0, 312) / blend [312, 536) / tail [536, 1168) spans cut ACROSS this table, and
// nothing about them changes. StencilFaceState is deliberately NOT reordered - reordering
// would move Espryt's shadow bytes for no gain.
namespace MobileGL::MG_Pipe {
namespace MGPipeRenderStateChunkDetail {
using RSP = RenderStateParameters;
using SFS = StencilFaceState;
inline constexpr SizeT kStencilFace0 = offsetof(RSP, StencilStates);
inline constexpr SizeT kStencilFace1 = kStencilFace0 + sizeof(SFS);
// The pipeline half of one stencil face is [Func, Ref) + [FailOp, end); the dynamic
// half is [Ref, FailOp) - Ref and ValueMask are VK_DYNAMIC_STATE_STENCIL_REFERENCE /
// _COMPARE_MASK and WriteMask is _WRITE_MASK, which is why glStencilFunc changing only
// the reference must not evict a cached pipeline (RenderState.cpp SetStencilFunc).
inline constexpr SizeT kFaceDynamicBegin = offsetof(SFS, Ref);
inline constexpr SizeT kFaceDynamicEnd = offsetof(SFS, FailOp);
} // namespace MGPipeRenderStateChunkDetail
// 15 chunks, 16 boundaries, strictly ascending, [0, sizeof(RenderStateParameters)).
inline constexpr SizeT kMGPipeRenderStateChunkCount = 15;
inline constexpr Array<SizeT, kMGPipeRenderStateChunkCount + 1> kMGPipeRenderStateChunkBoundaries = {
// D0 dynamic: Viewports[16], LineWidth, PointSize
SizeT{0},
// P0 pipeline: PatchVertices, PatchDefaultOuterLevel, PatchDefaultInnerLevel
offsetof(RenderStateParameters, PatchVertices),
// D1 dynamic: PolygonOffsetFactor/Units/Clamp, ClipOrigin, ClipDepthMode
offsetof(RenderStateParameters, PolygonOffsetFactor),
// P1 pipeline: BlendStates[8], LogicOp, DepthTestEnabled, DepthFunc, DepthMask,
// ColorMasks[8], FramebufferSrgbEnabled, DepthClampEnabled,
// TextureCubeMapSeamlessEnabled
offsetof(RenderStateParameters, BlendStates),
// D2 dynamic: ClearColor, ClearDepth, ClearStencil, BlendColor, DepthRanges[16]
offsetof(RenderStateParameters, ClearColor),
// P2 pipeline: SampleCoverageValue, SampleCoverageInvert, SampleMaskValue,
// MinSampleShadingValue, StencilStates[0].Func
offsetof(RenderStateParameters, SampleCoverageValue),
// D3 dynamic: StencilStates[0].{Ref, ValueMask, WriteMask}
MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin,
// P3 pipeline: StencilStates[0].{FailOp, PassDepthFailOp, PassDepthPassOp},
// StencilStates[1].Func
MGPipeRenderStateChunkDetail::kStencilFace0 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd,
// D4 dynamic: StencilStates[1].{Ref, ValueMask, WriteMask}
MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicBegin,
// P4 pipeline: StencilStates[1].{FailOp, PassDepthFailOp, PassDepthPassOp},
// CullFaceEnabled, CullFaceModeSetting, FrontFaceModeSetting,
// ProvokingVertexModeSetting
MGPipeRenderStateChunkDetail::kStencilFace1 + MGPipeRenderStateChunkDetail::kFaceDynamicEnd,
// D5 dynamic: the four hints, PointFadeThresholdSize, PointSpriteCoordOrigin,
// ClampReadColor
offsetof(RenderStateParameters, LineSmoothHint),
// P5 pipeline: PolygonModeFront, PolygonModeBack
offsetof(RenderStateParameters, PolygonModeFront),
// D6 dynamic: PrimitiveRestartIndex
offsetof(RenderStateParameters, PrimitiveRestartIndex),
// P6 pipeline: the 20 capability bools ColorLogicOpEnabled..ProgramPointSizeEnabled,
// ScissorTestEnabledMask
offsetof(RenderStateParameters, ColorLogicOpEnabled),
// D7 dynamic: ScissorBoxes[16], ScissorBoxWrittenMask, ClipDistanceEnabledMask
offsetof(RenderStateParameters, ScissorBoxes),
sizeof(RenderStateParameters),
};
// Chunk 0 is dynamic and they alternate, which is not a coincidence: every boundary above
// is a transition between a run of BumpVersions()-written members and a run of
// ++m_version-only members, so two adjacent chunks of the same half would mean a boundary
// that separates nothing.
constexpr Bool MGPipeRenderStateChunkIsPipeline(SizeT index) { return (index % 2) == 1; }
constexpr MGPStateChunk MGPipeRenderStateChunkAt(SizeT index) {
return MGPStateChunk{static_cast<Uint16>(kMGPipeRenderStateChunkBoundaries[index]),
static_cast<Uint16>(kMGPipeRenderStateChunkBoundaries[index + 1] -
kMGPipeRenderStateChunkBoundaries[index])};
}
namespace MGPipeRenderStateChunkDetail {
constexpr SizeT CountHalf(Bool pipeline) {
SizeT count = 0;
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) ++count;
}
return count;
}
constexpr SizeT BytesOfHalf(Bool pipeline) {
SizeT bytes = 0;
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
if (MGPipeRenderStateChunkIsPipeline(i) == pipeline) {
bytes += MGPipeRenderStateChunkAt(i).Length;
}
}
return bytes;
}
} // namespace MGPipeRenderStateChunkDetail
inline constexpr SizeT kMGPipePipelineChunkCount = MGPipeRenderStateChunkDetail::CountHalf(true);
inline constexpr SizeT kMGPipeDynamicChunkCount = MGPipeRenderStateChunkDetail::CountHalf(false);
// The CSO's content-addressed identity is exactly this many bytes; CsoCache stores them
// per entry and memcmps them on a hash hit.
inline constexpr SizeT kMGPipePipelineChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(true);
inline constexpr SizeT kMGPipeDynamicChunkBytes = MGPipeRenderStateChunkDetail::BytesOfHalf(false);
// Bumped by hand when something about the table changes that its BYTES do not show -
// the halves' membership, the meaning of a chunk, the gather order.
inline constexpr Uint64 kMGPipeRenderStateChunkTableVersion = 1;
// What actually seeds MGPipeComputePipelineSubsetHash. The version above is a promise a
// reader has to keep; this is the part that keeps itself. Folding the boundary table into
// the seed means a moved boundary invalidates every persisted key whether or not anyone
// remembered to bump the version - and it does so WITHOUT a static_assert on the
// boundaries, which would turn G7's negative control (which moves a boundary on purpose
// and must still compile) into a build break.
namespace MGPipeRenderStateChunkDetail {
constexpr Uint64 BoundaryChecksum() {
Uint64 hash = 0xcbf29ce484222325ull; // FNV-1a, 64-bit
for (SizeT i = 0; i <= kMGPipeRenderStateChunkCount; ++i) {
hash = (hash ^ static_cast<Uint64>(kMGPipeRenderStateChunkBoundaries[i])) * 0x100000001b3ull;
}
return hash;
}
} // namespace MGPipeRenderStateChunkDetail
inline constexpr Uint64 kMGPipeRenderStateChunkTableSeed =
kMGPipeRenderStateChunkTableVersion ^ MGPipeRenderStateChunkDetail::BoundaryChecksum();
// ---- the trip wires. A mistake in the table is a build break, here. ----
static_assert(kMGPipeRenderStateChunkBoundaries[0] == 0,
"the chunk table must start at byte 0 of RenderStateParameters");
static_assert(kMGPipeRenderStateChunkBoundaries[kMGPipeRenderStateChunkCount] ==
sizeof(RenderStateParameters),
"the chunk table must cover RenderStateParameters to its last byte");
static_assert(kMGPipePipelineChunkCount == 7);
static_assert(kMGPipeDynamicChunkCount == 8);
static_assert(kMGPipePipelineChunkCount + kMGPipeDynamicChunkCount == kMGPipeRenderStateChunkCount);
static_assert(kMGPipePipelineChunkBytes + kMGPipeDynamicChunkBytes == sizeof(RenderStateParameters),
"the two halves must partition the block exactly - no gap, no overlap");
static_assert(kMGPipeRenderStateChunkCount <= 32,
"a chunk index has to fit the Uint32 ChunkMask of MGPRenderStateDesc/MGPDynamicState");
// Sorted, non-overlapping and complete: because every chunk is [b[i], b[i+1]) the only
// way to violate that is a non-ascending boundary, so this is the whole check.
constexpr Bool MGPipeRenderStateChunkBoundariesAscend() {
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
if (!(kMGPipeRenderStateChunkBoundaries[i] < kMGPipeRenderStateChunkBoundaries[i + 1])) {
return false;
}
if (kMGPipeRenderStateChunkBoundaries[i + 1] > 0xffffu) return false;
}
return true;
}
static_assert(MGPipeRenderStateChunkBoundariesAscend(),
"the chunk boundaries must strictly ascend and fit MGPStateChunk's Uint16 fields");
// The measured sizes. They are DERIVED above; these two assertions only pin what the P2
// brief and MEASUREMENTS.md quote, so a table change that moves them is loud.
static_assert(kMGPipePipelineChunkBytes == 396, "the pipeline subset is 396 bytes");
static_assert(kMGPipeDynamicChunkBytes == 772, "the dynamic subset is 772 bytes");
// ---- global chunk bits, so nothing downstream hand-maintains a second table ----
// The GLOBAL chunk indices (bit i is chunk i of the 15) whose byte range overlaps
// [offset, offset + size). It falls straight out of the boundary table, which is the
// whole point: the applier scopes its derivation by the chunks a scatter actually moved
// (D5/D8), and a hand-written member -> chunk mapping is exactly the second table that
// would go stale the first time a boundary moves.
constexpr Uint32 MGPipeRenderStateChunkBitsCovering(SizeT offset, SizeT size) {
Uint32 bits = 0;
for (SizeT i = 0; i < kMGPipeRenderStateChunkCount; ++i) {
const SizeT begin = kMGPipeRenderStateChunkBoundaries[i];
const SizeT end = kMGPipeRenderStateChunkBoundaries[i + 1];
if (offset < end && begin < offset + size) bits |= Uint32{1} << i;
}
return bits;
}
// The wire masks are HALF-LOCAL (bit i of MGPRenderStateDesc::ChunkMask is pipeline chunk
// i); these widen them to the global indices the boundary table is written in. The
// halves alternate with chunk 0 dynamic, so the two conversions are arithmetic.
constexpr Uint32 MGPipeGlobalChunkBitsOfPipelineMask(Uint32 pipelineMask) {
Uint32 bits = 0;
for (SizeT i = 0; i < kMGPipePipelineChunkCount; ++i) {
if (((pipelineMask >> i) & 1u) != 0) bits |= Uint32{1} << (i * 2 + 1);
}
return bits;
}
constexpr Uint32 MGPipeGlobalChunkBitsOfDynamicMask(Uint32 dynamicMask) {
Uint32 bits = 0;
for (SizeT i = 0; i < kMGPipeDynamicChunkCount; ++i) {
if (((dynamicMask >> i) & 1u) != 0) bits |= Uint32{1} << (i * 2);
}
return bits;
}
inline constexpr Uint32 kMGPipeAllGlobalChunks =
static_cast<Uint32>((Uint64{1} << kMGPipeRenderStateChunkCount) - 1);
// The two conversions must agree with MGPipeRenderStateChunkIsPipeline, and together they
// must cover the table exactly - a widening that dropped or doubled a chunk would make
// the applier's scoping silently wrong rather than loud.
namespace MGPipeRenderStateChunkDetail {
inline constexpr Uint32 kAllPipelineHalfBits =
static_cast<Uint32>((Uint64{1} << kMGPipePipelineChunkCount) - 1);
inline constexpr Uint32 kAllDynamicHalfBits =
static_cast<Uint32>((Uint64{1} << kMGPipeDynamicChunkCount) - 1);
inline constexpr Uint32 kWidenedPipeline = MGPipeGlobalChunkBitsOfPipelineMask(kAllPipelineHalfBits);
inline constexpr Uint32 kWidenedDynamic = MGPipeGlobalChunkBitsOfDynamicMask(kAllDynamicHalfBits);
} // namespace MGPipeRenderStateChunkDetail
static_assert((MGPipeRenderStateChunkDetail::kWidenedPipeline &
MGPipeRenderStateChunkDetail::kWidenedDynamic) == 0,
"the two half-local -> global widenings must not overlap");
static_assert((MGPipeRenderStateChunkDetail::kWidenedPipeline |
MGPipeRenderStateChunkDetail::kWidenedDynamic) == kMGPipeAllGlobalChunks,
"the two half-local -> global widenings must cover the whole chunk table");
static_assert(MGPipeRenderStateChunkBitsCovering(0, sizeof(RenderStateParameters)) == kMGPipeAllGlobalChunks,
"every chunk must be covered by the whole block");
// ---- the operations everything else is written against ----
// The 396 pipeline bytes of `params`, in ascending chunk order, into `dst`.
void MGPipeGatherPipelineBytes(const RenderStateParameters& params, void* dst);
// The inverse: `src` is kMGPipePipelineChunkBytes bytes in the same order.
void MGPipeScatterPipelineBytes(const void* src, RenderStateParameters& dst);
// Incremental create_render_state: only the pipeline chunks named by `chunkMask` (bit i
// is pipeline chunk i, 0-based within the pipeline half), concatenated ascending.
SizeT MGPipePipelineChunkBlobBytes(Uint32 chunkMask);
void MGPipeGatherPipelineChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst);
void MGPipeScatterPipelineChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst);
// set_dynamic_state: bit i of `chunkMask` is dynamic chunk i, 0-based within the dynamic
// half; the blob is those chunks concatenated in ascending order.
SizeT MGPipeDynamicChunkBlobBytes(Uint32 chunkMask);
void MGPipeGatherDynamicChunks(const RenderStateParameters& params, Uint32 chunkMask, void* dst);
void MGPipeScatterDynamicChunks(const void* src, Uint32 chunkMask, RenderStateParameters& dst);
// Which dynamic chunks differ between two blocks - the chunk-level suppressor's answer.
Uint32 MGPipeDynamicChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b);
// Which pipeline chunks differ - the incremental-create mask against a base CSO.
Uint32 MGPipePipelineChunksThatMoved(const RenderStateParameters& a, const RenderStateParameters& b);
// XXH64 over the seven pipeline chunks in ascending order, seeded with the table version.
// Runs ONLY when m_pipelineStateVersion moved, i.e. never in the steady state.
Uint64 MGPipeComputePipelineSubsetHash(const RenderStateParameters& params);
// The same hash over already-gathered bytes (CsoCache holds them, so it does not re-gather).
Uint64 MGPipeHashPipelineBytes(const void* bytes);
} // namespace MobileGL::MG_Pipe
+92 -17
View File
@@ -231,8 +231,13 @@ namespace MobileGL::MG_Pipe {
// The half of the render state that must NOT mint a CSO: viewport, scissor, depth
// range, blend colour, line width, polygon offset, stencil ref/write mask, clear
// values, sample coverage, hints and the point-size family. This is what keeps
// glViewport from evicting Magma's pipeline memo (D-B1).
// values, hints, the point-size family and the primitive-restart index. This is what
// keeps glViewport from evicting Magma's pipeline memo (D-B1).
//
// SAMPLE COVERAGE IS NOT IN IT, and this comment used to say it was. P2's rule is that
// a byte is pipeline state if and only if a public RenderState setter that calls
// BumpVersions() writes it, and SetSampleCoverage does - so SampleCoverageValue and
// SampleCoverageInvert are in pipeline chunk P2 (MGPipeRenderStateSpans.h).
struct MGPDynamicState {
Uint32 ChunkMask;
Uint16 Version;
@@ -245,11 +250,29 @@ namespace MobileGL::MG_Pipe {
// VertexAttribute[32] AND the binding points, because a pointer-call stride of 0 means
// "element size" while a binding-model stride of 0 means "every vertex reads the same
// element" (section 4.5.3). IsLong and Type == Float64 are carried separately.
//
// THE BLOB RULE - ONE RULE FOR EVERY RECORD IN THIS HEADER THAT CARRIES AN MGPBlobRef,
// and MGPSubData below is the other one. `Blob.Size` is the record's own statement of how
// many bytes its blob holds, and the applier holds the record to that statement WHENEVER
// THE RECORD MAKES IT: a non-zero Blob.Size that disagrees with the byte length the
// record's other fields describe is Fatal{ProtocolCorruption} and the call is refused. A
// ZERO Blob.Size means "this record does not declare its blob", which is what a monolith
// emission is - the bytes travel beside the record through the apply entry point's
// companion `const void*` and no MGPBlobRef is filled - and it is not a fault. Either way
// the bytes read are bounded by the record's OTHER fields (the two counts here, the
// destination range there), so the length is a cross-check and never the safety property.
// A transport that fills these in gets a real gate on the first record it truncates; a
// client that leaves them zero gets no verify-build abort for a field it never used.
struct MGPVertexElements {
MGPipeHandle Cso;
Uint32 AttributeCount;
Uint32 BindingPointCount;
MGPBlobRef Blob; // VertexAttribute[] followed by VertexBufferBindingPoint[]
// MGPVertexAttribWire[AttributeCount] followed by
// MGPVertexBindingPointWire[BindingPointCount] - the WIRE views of MGPipeValueTypes.h,
// not VertexAttribute / VertexBufferBindingPoint, which cannot travel at all (each
// holds a SharedPtr<BufferObject>). The layout is stated once at
// MGPipeValueTypes.h's P3a block and unpacked once in PipeApply.cpp.
MGPBlobRef Blob;
};
MGP_ASSERT_POD(MGPVertexElements, 40);
@@ -348,6 +371,13 @@ namespace MobileGL::MG_Pipe {
};
MGP_ASSERT_POD(MGPFramebufferState, 304);
// GL_MAX_VERTEX_ATTRIBS as MobileGL advertises it, on the MG_Pipe side of the boundary.
// It bounds the two declared counts of MGPVertexElements, the applier's two per-CSO
// arrays and the vertex-buffer set. It MUST equal VertexArrayObject::MAX_VERTEX_ATTRIBS;
// MG_Impl/Pipe/PipeFill.cpp is the one translation unit that sees both and carries the
// static_assert, because this header may not include a frontend one.
inline constexpr Uint32 kMGPipeMaxVertexAttribs = 32;
struct MGPVertexBuffer {
MGPipeHandle Res;
Uint64 Offset;
@@ -361,9 +391,25 @@ namespace MobileGL::MG_Pipe {
// Var-tail header: MGPVertexBuffer[Count] follows.
struct MGPVertexBuffers {
Uint32 Start, Count;
// The vertex-FETCH base instance these offsets are valid for (P3a, D-H1). It is DRAW
// state, not VAO state, and it is NOT the same thing as MGPDrawInfo::StartInstance:
// that one is the GL draw's baseInstance and feeds gl_BaseInstance, this one is the
// shift the fetch address of an instanced array needs when the device has no native
// base-instance support. The server decides whether to emulate it or let
// GL_EXT_base_instance do the work - emulation is server-owned - so the client sends
// the draw's raw value and never a pre-shifted offset.
//
// IT IS A ContentHash INPUT, and that is a requirement rather than a nicety:
// set_vertex_buffers is suppressed on an unchanged hash (MG_Impl/Pipe/
// SetHashSuppressor.h's SetVertexBuffers slot), so a baseInstance that moved while
// the buffer set did not would be suppressed and the server would keep the previous
// shift. It rides ONE PER EMITTED SET rather than per entry: per entry the shift is
// redundant and lets a malformed record disagree with itself.
Uint32 BaseInstance;
Uint32 Pad0;
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPVertexBuffers, 16);
MGP_ASSERT_POD(MGPVertexBuffers, 24);
// An independent call, NOT a subset of the VAO configuration version (D5).
struct MGPIndexBuffer {
@@ -514,14 +560,17 @@ namespace MobileGL::MG_Pipe {
// to it because both sides are the same translation unit. G3 emits the offsetof
// assertions; under split the block is serialized field-wise rather than memcpy'd.
struct ResidualValueBlock {
RenderStateParameters RenderState; // until create/bind_render_state + set_dynamic_state land
PixelStoreParameters Pack; // until set_pixel_pack_state lands
// The 35 CapabilityInput bits, packed in enum order. P2 retired everything else:
// RenderStateParameters to create/bind_render_state + set_dynamic_state, Pack to
// set_pixel_pack_state, and the patch quintet to set_patch_state.
//
// What is left is deliberately REDUNDANT. Every one of the 35 capabilities is
// answerable from the assembled working block now that P2 gave FramebufferSrgb,
// DepthClamp and TextureCubeMapSeamless real storage - which is the point: the
// applier compares the two answers bit by bit, so the day a later call takes a
// capability over and forgets to carry it, the block says so on the next draw
// (Fatal{PipeResidualDiverged, "<Cap>"}, MG_Pipe/PipeApply.cpp).
Uint64 CapabilityBits;
Uint32 PatchVertices;
Uint32 Pad0;
Float PatchOuter[4];
Float PatchInner[2];
Uint32 Pad1[2];
};
static_assert(std::is_trivially_copyable_v<ResidualValueBlock>);
// The retirement ratchet. This number only ever goes DOWN: every stage that lands a real
@@ -530,10 +579,12 @@ namespace MobileGL::MG_Pipe {
// gone. Shrinking the block without lowering the number, or growing it at all, is a build
// break - which is the point.
//
// Stable across the ABIs MobileGL ships on: every member of RenderStateParameters and
// PixelStoreParameters is a fixed-width scalar or an array of one, with no pointer and no
// SizeT.
#define MGL_RESIDUAL_BLOCK_SIZE 1248
// Stable across the ABIs MobileGL ships on: the one member is a fixed-width scalar.
//
// P2: 1248 -> 8. RenderStateParameters (1168) retired to create/bind_render_state and
// set_dynamic_state, PixelStoreParameters (28) to set_pixel_pack_state, and the patch
// quintet (52 with its padding) to set_patch_state.
#define MGL_RESIDUAL_BLOCK_SIZE 8
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE,
"the residual value block changed size; lower MGL_RESIDUAL_BLOCK_SIZE if a field "
"retired, and do not raise it");
@@ -568,8 +619,16 @@ namespace MobileGL::MG_Pipe {
//
// THE BUFFER HALF. With Target == Buffer there is no level and no box, so the destination
// byte range rides in the box's first coordinate and first extent: UnionBox.X is the byte
// offset, UnionBox.W the byte size, Y = Z = 0, H = D = 1, Level = 0, RegionCount = 0, and
// Blob holds exactly Size source bytes. That caps ONE record at a 2^31-1 offset and a
// offset, UnionBox.W the byte size, Y = Z = 0, H = D = 1, Level = 0, RegionCount = 0.
//
// THE BLOB RULE, THE SAME ONE MGPVertexElements ABOVE CARRIES: `Blob.Size` is the record's
// own statement of how many bytes its blob holds and the applier holds the record to that
// statement whenever the record makes it - a non-zero Blob.Size that is not exactly the
// record's own byte size (MGPipeSubDataBufferSize below) is Fatal{ProtocolCorruption} and
// the write is refused. A zero Blob.Size means "this record does not declare its blob",
// which is what a monolith emission is: the bytes travel beside the record through
// MGPipeApplyResourceSubData's companion `const void*`. The destination range is what
// bounds the write in both cases. That caps ONE record at a 2^31-1 offset and a
// 2^32-1 size; a range beyond either is split by the emitter - the same rule, and at
// SEG_STAGE's 32 MiB the far tighter one, that the ring's half-capacity bound already
// imposes on it. MGPipeSetSubDataBufferRange / MGPipeSubDataBufferOffset / Size below are
@@ -606,6 +665,22 @@ namespace MobileGL::MG_Pipe {
}
inline Uint64 MGPipeSubDataBufferSize(const MGPSubData& record) { return record.UnionBox.W; }
// P3a, D-A5: the per-record half of resource_respecify's kNeedsAck.
//
// Flags are a PER-CALL static property and resource_respecify serves BOTH glBufferData
// and glBufferStorage. A bare kNeedsAck on the call would acknowledge every glBufferData
// in a world upload; only glBufferStorage is a real synchronous allocation and only it is
// allowed a synchronous ack. So kNeedsAck on the call means "records of this call MAY
// require an acknowledgement" and THIS predicate decides per record. In monolith the ack
// is ((void)0) - the applier is one function call away - and the transport wires the
// doorbell to this predicate when it lands.
//
// Immutable is exactly the right discriminator: it is set iff the store came from a
// glBufferStorage* entry point, which is the definition of the allowed case.
inline Bool MGPipeResourceRespecifyNeedsAck(const MGPResourceDesc& desc) {
return desc.Immutable != 0;
}
// The forward terminator for a server-initiated texture pull (section 7.1). May carry
// zero regions - that is how a pull that needs nothing is answered.
struct MGPSubDataComplete {
+81
View File
@@ -289,6 +289,21 @@ namespace MobileGL {
// Every entry is initialized to all-true in RenderState's constructor.
Array<BoolVec4, kMGMaxDrawBuffers> ColorMasks;
// GL_FRAMEBUFFER_SRGB / GL_DEPTH_CLAMP / GL_TEXTURE_CUBE_MAP_SEAMLESS. Until P2 these
// three fell to SetCapability's "not supported currently" arm - glEnable was swallowed
// and IsCapabilityEnabled answered a compile-time false, so DirectGLES' sRGB block and
// the DirectVulkan read points consumed a constant while glIsEnabled lied about it.
// Placed HERE, in the three alignment bytes between ColorMasks (32 bytes, align 1) and
// ClearColor (align 4), so sizeof(RenderStateParameters) stays 1168 and no existing
// offset moves: the Espryt span constants and the P2 chunk table both depend on that.
// All three are PIPELINE state (their setters call BumpVersions): FramebufferSrgb is
// what ARCHITECTURE.md 5.3 asks for, DepthClamp is
// VkPipelineRasterizationStateCreateInfo::depthClampEnable, and TextureCubeMapSeamless
// changes sampler interpretation.
Bool FramebufferSrgbEnabled = false;
Bool DepthClampEnabled = false;
Bool TextureCubeMapSeamlessEnabled = false;
// Clear State
FloatVec4 ClearColor = FloatVec4(0.0f, 0.0f, 0.0f, 1.0f);
Float ClearDepth = 1.0f;
@@ -529,6 +544,61 @@ namespace MobileGL {
};
} // namespace MG_State::GLState
// ---- P3a: the WIRE forms of the two views above (ARCHITECTURE.md section on vertex
// elements; brief D-G2). Neither VertexAttribute nor VertexBufferBindingPoint can travel
// as itself: both hold a SharedPtr<BufferObject>, and a payload never contains a pointer.
// They live here rather than in MGPipeTypes.h so the structs they mirror are one screen
// away and a member added above has its wire twin in view; MGPipeTypes.h includes this
// header, so MG_Pipe sees them unqualified like every other value type.
//
// Both ride the create_vertex_elements BLOB, in ascending index order, attributes first:
// MGPVertexAttribWire[AttributeCount] then MGPVertexBindingPointWire[BindingPointCount],
// each count <= VertexArrayObject::MAX_VERTEX_ATTRIBS (32). The applier refuses a record
// whose declared counts do not match the blob's declared size.
// The resolved flat attribute view. Buffer identity does NOT travel here - it travels in
// set_vertex_buffers, which is what keeps this record stable while buffers change under
// it. Stride is the RESOLVED distance and a surviving 0 can only have come from the
// binding model (see VertexAttribute::Stride above); collapsing it back into the element
// size is what made KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the
// buffer. Divisor is deliberately ABSENT: it is resolved per binding point and travels in
// MGPVertexBuffer::Divisor, which is where the backend's glVertexAttribDivisor reads it.
// LegacyStride / LegacyPointer are likewise absent - they are the glGetVertexAttrib*
// query answers and stay client-side, because nothing but the query path reads them.
struct MGPVertexAttribWire {
Uint64 Offset; // 0
Int32 Stride; // 8
Uint32 Type; // 12 DataType
Uint8 Size; // 16 1..4; GL_BGRA keeps 4
Uint8 Enabled; // 17
Uint8 Normalized; // 18
Uint8 IsInteger; // 19
// CARRIED SEPARATELY from Type == Float64, and it has to be: VertexAttribFormat(
// GL_DOUBLE) also reads doubles from memory but asks for them converted to float,
// while VertexAttribLFormat keeps all 64 bits. The backend's fp64 narrowing and its
// Adreno disabled-attribute workaround both key on telling the two apart.
Uint8 IsLong; // 20
Uint8 IsBgra; // 21
Uint8 BindingIndex; // 22 which MGPVertexBuffer entry feeds it (< MAX_VERTEX_ATTRIBS)
Uint8 Pad0; // 23
};
// The ARB_vertex_attrib_binding view. Buffer identity is again in set_vertex_buffers.
//
// WHY IT TRAVELS AT ALL, since no backend has ever read a binding point (the frontend
// resolves them eagerly into the flat view above, and grep finds zero backend reads of
// VertexBufferBindingPoint / GetAttributeBindingIndex / GetAttributeRelativeOffset): the
// record DECLARES BindingPointCount, PipeFields.def names it, and a record whose declared
// counts do not describe its own blob is a shape the applier's bounds gate would have to
// police forever. Carrying both views keeps the record self-describing, and the cost is
// paid once per configuration change rather than per draw - the blob rides only on
// create_vertex_elements.
struct MGPVertexBindingPointWire {
Uint64 Offset; // 0
Int32 Stride; // 8 GL 4.6 core table 23.4: the INITIAL value is 16, not 0
Uint32 Divisor; // 12
};
// ---- trip wires (P0.5). Sizes are what every ABI MobileGL ships on produces: every
// member is a fixed-width scalar, an enum of one, or an array of those - no pointer, no
// SizeT - except the vertex types, which carry SharedPtr<BufferObject> by design and are
@@ -545,5 +615,16 @@ namespace MobileGL {
static_assert(std::is_trivially_copyable_v<SamplerParameters> && sizeof(SamplerParameters) == 100);
static_assert(std::is_trivially_copyable_v<MG_State::GLState::VertexAttributeVersion> &&
sizeof(MG_State::GLState::VertexAttributeVersion) == 6);
// The two P3a wire views. Unlike the structs they mirror these ARE flat PODs with
// explicit padding, so the trip wire is the same one every MGPipe payload carries: the
// blob they ride in is memcpy'd, and a field silently changing width is a protocol break
// no test would otherwise see. (MGP_ASSERT_POD is MGPipeTypes.h's and that header
// includes this one, so the assertions are spelled out here instead.)
static_assert(std::is_trivially_copyable_v<MGPVertexAttribWire> &&
sizeof(MGPVertexAttribWire) == 24);
static_assert(std::is_standard_layout_v<MGPVertexAttribWire>);
static_assert(std::is_trivially_copyable_v<MGPVertexBindingPointWire> &&
sizeof(MGPVertexBindingPointWire) == 16);
static_assert(std::is_standard_layout_v<MGPVertexBindingPointWire>);
} // namespace MobileGL
#endif // MOBILEGL_MG_PIPE_VALUE_TYPES_H
File diff suppressed because it is too large Load Diff
+432
View File
@@ -0,0 +1,432 @@
// MobileGL - MobileGL/MG_Pipe/PipeApply.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include "MGPipeRenderStateSpans.h"
#include "MGPipeTypes.h"
// The in-process applier: the SERVER half of the calls P2 emits. Under split this file is
// MG_Remote/Server/PipeApplier (ARCHITECTURE.md 8.3); in the monolith it writes
// MG_Backend/MGPipe/PipeInputs' gPipeInputs directly, so a call and its effect are one
// function call apart and nothing is serialised.
//
// THE SERVER'S PER-CONTEXT WORKING BLOCK *IS* PipeInputs::m_renderState. bind_render_state
// and set_dynamic_state scatter their chunks straight into it, which is why DirectGLES'
// SyncRenderState is not one line changed (ROADMAP.md P2, G5): the block Espryt binds by
// const reference is the assembled block. It is also what makes the MOBILEGL_PIPE_VERIFY
// comparator a real oracle instead of a tautology - the compare-at-read now proves
// "assembled == live", field by field, at every backend read.
//
// This header FORWARD-DECLARES PipeInputs rather than including it: the applier's callers
// (MG_Impl/Pipe) already have it, and MG_Pipe sits below MG_Backend.
//
// Compiled only under MOBILEGL_PIPE_PUSH (CMakeLists.txt), so the pull build gains no symbol.
namespace MobileGL::MG_Pipe {
struct PipeInputs;
// ---------------------------------------------------------------------------------
// The CSO store
// ---------------------------------------------------------------------------------
// One record per live render-state CSO, indexed by MGPipeHandle::Slot. It keeps the 396
// pipeline bytes because an incremental create_render_state names only the chunks that
// moved against a BaseCso - the rest has to come from somewhere, and that somewhere is
// the record the client is naming.
struct MGPipeRenderStateCsoRecord {
Uint32 Gen = 0;
Bool Live = false;
Array<Uint8, kMGPipePipelineChunkBytes> PipelineBytes{};
};
// ---------------------------------------------------------------------------------
// P3a: the handle-shaped resource op table (D-A1)
// ---------------------------------------------------------------------------------
// The SECOND backend op table, beside BufferBackendOps. Registered by the active backend
// at bring-up and cleared at shutdown, exactly as that one is; a null table means "this
// backend has not taken the resource family over", and the frontend then dispatches the
// old way, which is what lets the client half land on its own and what keeps a backend
// whose buffer path is a later phase untouched.
//
// NO FRONTEND TYPE APPEARS HERE, and that is the whole point of the conversion: every
// hook it replaces took a frontend heap reference and four of them read that object's
// shadow bytes. A resource is an MGPipeHandle plus a payload record plus, where the call
// carries content, a companion `const void*`.
//
// THE COMPANION POINTER IS NOT A NEW IDEA - MGPipeApplyCreateRenderState already carries
// a blob beside its POD for the same reason: in monolith a blob needs no MGPBlobRef and
// the pointer is the client's own shadow base, so the call is zero-copy and behaviour is
// unchanged. How those bytes cross under a real transport is that phase's problem and
// that phase's flag edit; resource_respecify deliberately does NOT carry kHasBlob here,
// because a kHasBlob record must own an MGPBlobRef member and MGPResourceDesc has none.
//
// SubDataResident MAY BE NULL and stays nullable on purpose: one backend deliberately
// does not implement it (kOptional in the catalogue), the frontend checks it exactly as
// it checks the op table it replaces, and giving that backend a real implementation is a
// behaviour change that belongs in its own change, not in this migration.
struct MGPipeResourceOps {
void (*Create)(MGPipeHandle res, const MGPResourceDesc& desc);
void (*Respecify)(MGPipeHandle res, const MGPResourceDesc& desc, const void* initialBytes);
void (*SubData)(MGPipeHandle res, const MGPSubData& record, const void* bytes);
// kOptional: may be null. `bytes` is the application's staging store and is valid for
// the duration of the call only.
void (*SubDataResident)(MGPipeHandle res, const MGPSubData& record, const void* bytes);
void (*FlushRange)(MGPipeHandle res, const MGPFlushRange& record, const void* bytes);
void (*Readback)(MGPipeHandle res, const MGPReadback& record);
void (*Destroy)(MGPipeHandle res);
void* (*MapPersistent)(MGPipeHandle res, Uint64 size, const void* seedBytes);
void (*UnmapPersistent)(MGPipeHandle res);
};
// Install / read the table. A null argument uninstalls, which is what a backend does at
// context teardown and what every build that has not migrated the family sits at.
void MGPipeSetResourceOps(const MGPipeResourceOps* ops);
const MGPipeResourceOps* MGPipeGetResourceOps();
// ---------------------------------------------------------------------------------
// P3a: the applier's own records (D-G4)
// ---------------------------------------------------------------------------------
// THE SLOT A CLIENT MAY NAME IS BOUNDED, and the bound lives here rather than at the
// client's allocator because the two tables below are grown BY the slot index. An array a
// handle indexes is the right shape for a dense slot space (MGPipeHandles.h) and the price
// of that shape is that one corrupt Uint32 in a payload otherwise arrives at an allocator
// as a four-billion-entry request from inside the bounds gate's own commit. A slot at or
// above these is Fatal{ProtocolCorruption} - the same verdict as any other record that
// would make the server act outside its own storage - and never a resize.
//
// The two numbers differ because the two records do: a resource record is descriptor-sized
// and a vertex-elements record carries both unpacked views at ~1.3 KB, so one bound would
// mean two very different worst cases. Both are far above what a GL application has live
// at once, and NEITHER IS EVER ALLOCATED BY BEING NAMED: the tables grow to the client's
// own dense high-water mark and no further, so the bound costs nothing until a record is
// already corrupt. Package C bounds handle.Slot the same way before
// BackendSlotTable::EntryAt, which resizes on a client-supplied index too.
inline constexpr Uint32 kMGPipeMaxResourceSlots = 1u << 20;
inline constexpr Uint32 kMGPipeMaxVertexElementsSlots = 1u << 16;
// One record per live resource, indexed by MGPipeHandle::Slot, kind Buffer; slot 0 is the
// reserved null handle and is never live.
struct MGPipeResourceRecord {
Uint32 Gen = 0;
Bool Live = false;
// The last create/respecify, verbatim. The backend reads its Width / Usage /
// StorageFlags / HasDefinedContent instead of asking the frontend object.
MGPResourceDesc Desc{};
// SERVER-OWNED, monotone, and it never crosses the line: an MGGen-class counter, ++ on
// every mutation this applier applies (respecify, sub-data, flush range, resident
// sub-data). It is what replaces the frontend change serial the backend used to
// mirror, and no MGPipe call may require the client to provide or know one.
Uint64 Serial = 0;
// ALWAYS FALSE IN P3a, AND WRITTEN BY NOBODY. It exists so the phase that pushes
// persistent-mapped host writes can set it with zero new record kinds; a verify build
// pins that it is false, so that phase cannot land a silent semantic change under it.
Bool HasLiveHostWrites = false;
};
// The vertex-elements CSO as the applier holds it: the unpacked blob, both views, plus
// the serial the backend's per-VAO twin compares against instead of a wrapping Uint16
// configuration version plus an identity patch.
//
// The 32 is GL's MAX_VERTEX_ATTRIBS as MobileGL advertises it (kMGPipeMaxVertexAttribs,
// MGPipeTypes.h), which is also the bound the record's two declared counts are checked
// against before the blob is unpacked.
struct MGPipeVertexElementsRecord {
Uint32 Gen = 0;
Bool Live = false;
Uint32 AttributeCount = 0;
Uint32 BindingPointCount = 0;
Array<MGPVertexAttribWire, kMGPipeMaxVertexAttribs> Attributes{};
Array<MGPVertexBindingPointWire, kMGPipeMaxVertexAttribs> BindingPoints{};
// Server-owned MGGen, ++ on every create_vertex_elements applied to this handle -
// including a RE-create on the same handle, which is how a configuration change
// travels (the handle is minted per frontend VAO and Gen moves only on slot reuse).
Uint64 ContentSerial = 0;
};
struct MGPipeApplierState {
// Indexed by slot; slot 0 is the reserved null handle and is never live
// (MGPipeHandles.h kMGPipeFirstAllocatableSlot).
Vector<MGPipeRenderStateCsoRecord> RenderStateCsos;
// The last bind, so a rebind of the same handle can be answered without a scatter.
MGPipeHandle BoundRenderStateCso = kMGPipeNullHandle;
// The residual block as last received. Compared against the assembled state on every
// set_residual_value_state; a disagreement is the D9 trip wire.
ResidualValueBlock Residual{};
Bool HasResidual = false;
// The GLOBAL chunk bits (MGPipeRenderStateSpans.h's numbering) this applier has
// itself scattered into the working block since the last reset - its own ledger of
// which bytes of PipeInputs::m_renderState are the APPLIER'S rather than the per-verb
// fill loop's. Both trip wires arm off it, and that is the whole of their contract:
//
// - with the render-state subsystem OFF (MOBILEGL_PIPE_PUSH bit 0 clear - the
// per-subsystem A/B of D14) nothing is ever scattered, the ledger stays empty and
// the wires say nothing. The working block is then the fill loop's, published per
// VERB CLASS (MG_Pipe/FillPoints.def), so at a kDispatch or kTextureOp verb - the
// two classes that publish IsCapabilityEnabled but NOT GetRenderStateParameters -
// it still holds the previous draw's bytes and is an oracle for nothing;
// - with it ON the applier is the block's only writer (D5 takes an emitted field
// out of the fill loop), so the bytes it has scattered are current at every verb
// of every class and comparing against them is honest.
//
// set_patch_state's own write to the working block deliberately does NOT enter the
// ledger: that is the OTHER carrier, and a wire comparing against bytes it had just
// written itself would be a tautology.
Uint32 ScatteredChunkBits = 0;
// What the two trip wires last did. A wire nothing can observe is a gate that cannot
// go red for the reason it exists (ROADMAP.md), and only a poison or verify build
// aborts: the shipped push build counts and logs, so these counters are how a unit
// case sees the wire fire in EVERY build rather than in one.
Uint32 ResidualCapabilitiesCompared = 0; // of the 35, at the last set_residual_value_state
Uint32 ResidualDivergences = 0; // cumulative
Uint32 PatchCarrierComparisons = 0; // cumulative, armed set_patch_state calls only
Uint32 PatchCarrierDivergences = 0; // cumulative
// ---- P3a (D-G4). ----
//
// THE TWO HALVES BELOW HAVE DIFFERENT LIVES, and MGPipeApplierReset is where the
// difference is spent: the OBJECT RECORDS describe GL objects and outlive a
// make-current; the WORKING STATE describes what the next draw fetches with and does
// not. Reading the whole block as "per context" is what dropped a shared buffer's
// record at every context switch and made the write that followed it disappear.
// ---- object records: indexed by MGPipeHandle::Slot of kind Buffer /
// VertexElementsCso, and NOT part of the working state.
//
// A GL object lives in a SHARE GROUP, not in a context: a buffer created before a
// make-current is the same buffer, with the same storage, after it, and its record is
// the only thing the backend has left to read that storage's extent and mutation
// serial out of (D-A4 re-keys IsBufferDrawClean onto exactly those two). Dropping the
// records at a make-current would therefore make every subsequent glBufferSubData on a
// pre-existing buffer resolve to nothing and be refused - a lost write, in a build
// where the refusal's assertion has compiled out.
//
// They are cleared by the object's OWN death signal - resource_destroy,
// delete_vertex_elements, which is what D-L makes the buffer's death crossing - and by
// MGPipeApplierReleaseObjectRecords when the served context and its applier go away.
// Nothing else.
Vector<MGPipeResourceRecord> Resources;
Vector<MGPipeVertexElementsRecord> VertexElementsCsos;
// Every call this applier REFUSED because it named a record this applier does not
// have: an unknown slot, a slot that is not live, or a generation that has moved on
// under it. The refusal is a defined no-op - nothing stored, nothing dispatched, no
// serial moved - for the reason written beside kResourceRefusalNote in PipeApply.cpp,
// but A NO-OP NOBODY CAN SEE IS A DROPPED CALL NOBODY CAN SEE: MOBILEGL_ASSERT compiles
// out at INFO, which is what all three gate builds and every shipped build are, so
// these two are how a unit case - and an operator reading a log - observe it in EVERY
// build. Per context, like the four render-state wire counters above.
Uint64 RefusedResourceCalls = 0;
Uint64 RefusedVertexInputCalls = 0;
// ---- working state: what the next draw fetches with. All of it is per context and
// all of it is cleared by MGPipeApplierReset, EXCEPT the two serials, which only ever
// advance (see there).
// The last bind_vertex_elements. Null is legal and means "no VAO bound".
MGPipeHandle BoundVertexElements = kMGPipeNullHandle;
// The last set_vertex_buffers, as received: the entries, the window they describe,
// and the fetch base instance they are valid for.
Array<MGPVertexBuffer, kMGPipeMaxVertexAttribs> VertexBuffers{};
Uint32 VertexBufferStart = 0;
Uint32 VertexBufferCount = 0;
// The RAW value the client sent (MGPVertexBuffers::BaseInstance). It is NOT a resolved
// shift: whether the fetch shift has to be emulated at all is a backend capability - a
// device with native base-instance support shifts nothing - and emulation is
// server-owned, so the backend arm turns this into a per-attribute byte shift out of
// each attribute's own stride and divisor. This header sits below MG_Backend and may
// not ask that question. The client never pre-shifts an offset and never learns the
// answer.
Uint32 VertexFetchBaseInstance = 0;
// Server-owned MGGen, ++ on every applied set_vertex_buffers. It is what retires the
// backend twin's wrapping-Uint16-plus-identity patches - which means IT MUST NEVER
// HAND OUT A VALUE TWICE. A reset ADVANCES it (the cleared window is itself a change
// the twin has to hear about) and never returns it to 0: a counter that restarts walks
// back through every value it has already stamped into a twin that outlived the
// switch, and the identity patch that used to close that hole is exactly what D-G4
// deletes on the twin's side.
Uint64 VertexBuffersSerial = 0;
// The last set_index_buffer. Independent of the vertex-elements configuration by
// design (D5): the index slot is not part of a VAO's configuration version.
MGPIndexBuffer IndexBuffer{};
// Advanced, never zeroed, for VertexBuffersSerial's reason.
Uint64 IndexBufferSerial = 0;
// Every map_persistent EMISSION, i.e. every acquisition attempt - mint OR decline -
// because every one of them needs an answer from the resource owner. In monolith the
// answer is free; under a transport it is a real round trip. The number is therefore
// the same in both modes and is "one per storage definition", which is what makes it
// assertable today instead of a counter that can only ever read zero. The counter an
// operator greps is PipeStats' map-persistent-roundtrips (mpr); this member is the
// applier-side observable a unit case reads without a stats window.
Uint64 MapPersistentRoundtrips = 0;
};
// The monolith's single applier. Under split there is one per served context.
MGPipeApplierState& MGPipeApplier();
// A MAKE-CURRENT, NOT A TEARDOWN - and the distinction is the whole of this function's
// contract. It runs on every change of the current GLContext (MGPipeTracker::Update resets
// the tracker whenever the context pointer moves, and the emitter calls this from the
// first walk that follows), including a make-current BACK to a context that is still alive
// and whose objects are all still there.
//
// So it drops what a returning context may not inherit - the render-state CSOs (whose
// client-side cache is dropped on the line above it, so both sides start over together),
// the residual mirror, and the vertex-input WORKING state - and it ADVANCES the two global
// vertex-input serials rather than zeroing them. It does NOT drop the resource or
// vertex-elements records: those describe share-group objects that the switch does not
// destroy, and dropping them is a dropped write on the far side of it.
void MGPipeApplierReset();
// THE OTHER SCOPE: the served context is going away and its applier with it, so the object
// records go too. Under split that is one applier per served context and this is its
// teardown. In the monolith there is ONE applier behind every context, so this is
// deliberately wired to NOTHING: a record is cleared by its object's own death signal
// (resource_destroy, delete_vertex_elements) and the process's exit clears the rest.
// Calling it on one context's destruction in a monolith would drop every other context's
// records, which is the C1 hole in its other direction.
void MGPipeApplierReleaseObjectRecords();
// ---------------------------------------------------------------------------------
// The seven apply entry points (ARCHITECTURE.md 5.3, ROADMAP.md P2)
// ---------------------------------------------------------------------------------
// create_render_state. `chunkBytes` is the pipeline chunks named by desc.ChunkMask,
// concatenated in ascending chunk order (MGPipeGatherPipelineChunks' output). A
// brand-new CSO must name every chunk; an incremental one starts from desc.BaseCso.
void MGPipeApplyCreateRenderState(const MGPRenderStateDesc& desc, const void* chunkBytes);
// bind_render_state: 12 bytes, no blob, no hashing. Scatters the record's seven pipeline
// chunks into the working block and publishes both versions.
void MGPipeApplyBindRenderState(const MGPBindRenderState& bind);
// delete_render_state: frees the slot. The client's allocator owns the Gen bump on
// REUSE; the record only stops being live here. CsoCache's LRU eviction emits this.
void MGPipeApplyDeleteRenderState(const MGPHandleOnly& handle);
// set_dynamic_state: the dynamic chunks named by dyn.ChunkMask, concatenated ascending.
void MGPipeApplySetDynamicState(const MGPDynamicState& dyn, const void* chunkBytes);
// set_pixel_pack_state. PACK only, deliberately (MGPipeTypes.h, ARCHITECTURE.md 4.6 D5).
void MGPipeApplySetPixelPackState(const MGPPixelPackState& pack);
// set_patch_state. The trio also travels in pipeline chunk P0, and the applier asserts
// under verify that the two carriers agree - the redundancy is a trip wire, not waste.
void MGPipeApplySetPatchState(const MGPPatchState& patch);
// set_vertex_attrib_defaults: `tail` is hdr.Count MGPAttribValues for the attributes
// named by hdr.Mask, in ascending location order.
void MGPipeApplySetVertexAttribDefaults(const MGPVertexAttribDefaults& hdr, const MGPAttribValue* tail);
// set_residual_value_state: what has no call of its own. Since P2 that is one Uint64 of
// capability bits, and every one of them is ALSO answerable from the assembled working
// block - which is the point. A disagreement is Fatal{PipeResidualDiverged, "<Cap>"}.
void MGPipeApplySetResidualValueState(const ResidualValueBlock& block);
// ---------------------------------------------------------------------------------
// P3a: the nine resource entry points (D-A1, D-A2)
// ---------------------------------------------------------------------------------
//
// These are the ONE exception to push-at-validate: they are applied at the GL call that
// causes them, from the same dispatchers that call the old op table today, because that
// is already where those hooks run. Nothing about buffers moves to validate time here.
//
// The `bytes` companion of the three content-carrying calls is the client's shadow base,
// never a copy (see MGPipeResourceOps). A null is a real answer wherever the payload says
// the content is undefined.
//
// AT THE CONTRACT COMMIT EVERY BODY BELOW IS A STUB. The signatures are what the client,
// the backend and the gates compile against, and the records above are what they write
// into; the bodies land in the two commits that follow this one on the same branch.
// resource_create: mints the record and marks the slot Live. Emitted from the buffer
// object's CONSTRUCTOR, so a resource exists before anything can name it; storage is
// defined lazily by the first respecify and a backend tolerates a resource with none.
void MGPipeApplyResourceCreate(const MGPResourceDesc& desc);
// resource_respecify: replaces the stored descriptor and bumps Serial. `initialBytes` is
// the shadow when desc.HasDefinedContent, else null. kNeedsAck on the call,
// MGPipeResourceRespecifyNeedsAck(desc) per record - only an immutable store acks.
void MGPipeApplyResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes);
// resource_subdata, buffer half: the destination range rides in the record's box through
// MGPipeSetSubDataBufferRange, and a false from that helper is where the EMITTER split.
// The applier stores nothing per record - contents are the backend's - and bumps Serial.
void MGPipeApplyResourceSubData(const MGPSubData& record, const void* bytes);
// buffer_subdata_resident: same shape; `bytes` is the application's staging store and is
// valid for the duration of the call only. The op-table entry may be null.
void MGPipeApplyBufferSubDataResident(const MGPSubData& record, const void* bytes);
// resource_flush_range: record.AccessFlags are the application's REAL mapping flags, not
// a normalised subset - the backend reads them per call to choose its upload shape.
void MGPipeApplyResourceFlushRange(const MGPFlushRange& record, const void* bytes);
// resource_readback: whole-buffer by contract. The answer travels back through the
// reverse channel, and the writeback happens BEFORE the mutation epoch bumps, never
// after - the ordering is a correctness rule, not a preference.
void MGPipeApplyResourceReadback(const MGPReadback& record);
// resource_destroy: clears Live and drops the record, then the backend frees its twin.
// The CLIENT frees the slot afterwards, in that order, because the allocator forgets the
// lifetime id on free and a notice resolved twice finds nothing the second time.
void MGPipeApplyResourceDestroy(const MGPHandleOnly& handle);
// map_persistent: bumps MapPersistentRoundtrips and asks the backend. Returns the
// coherent host pointer the resource owner donated, or null for a DECLINE - which is a
// real answer and the reason the call is kOptional as well as kReplySlot. `seedBytes` is
// the shadow, still live at this point, for the backends that seed the new store from it.
void* MGPipeApplyMapPersistent(const MGPHandleOnly& handle, Uint64 size, const void* seedBytes);
// unmap_persistent: the donation ends. Never emitted by P3a's own paths; the call exists
// so the pair is complete and the transport has both halves.
void MGPipeApplyUnmapPersistent(const MGPHandleOnly& handle);
// ---------------------------------------------------------------------------------
// P3a: the five vertex-input entry points (D-G, D-H, D-I)
// ---------------------------------------------------------------------------------
// create_vertex_elements. `blobBytes` is MGPVertexAttribWire[desc.AttributeCount]
// immediately followed by MGPVertexBindingPointWire[desc.BindingPointCount], both in
// ascending index order. The applier REFUSES a record whose declared counts do not
// describe its own blob, and both counts are bounded by kMGPipeMaxVertexAttribs.
// Re-issuing on the same handle is how a configuration change travels; it bumps
// ContentSerial and does not rebind.
void MGPipeApplyCreateVertexElements(const MGPVertexElements& desc, const void* blobBytes);
// bind_vertex_elements. The null handle is legal and means "no VAO bound".
void MGPipeApplyBindVertexElements(const MGPHandleOnly& handle);
// delete_vertex_elements: emitted from ONE place, the frontend object's death notice.
void MGPipeApplyDeleteVertexElements(const MGPHandleOnly& handle);
// set_vertex_buffers: `tail` is hdr.Count MGPVertexBuffer entries starting at hdr.Start.
// hdr.BaseInstance is the DRAW's raw base instance and is stored, unresolved, in
// VertexFetchBaseInstance - the decision whether to emulate the fetch shift is the
// backend's, for the reason written beside that member. Bumps VertexBuffersSerial.
void MGPipeApplySetVertexBuffers(const MGPVertexBuffers& hdr, const MGPVertexBuffer* tail);
// set_index_buffer: an independent call, NOT a subset of the vertex-elements
// configuration. Bumps IndexBufferSerial.
void MGPipeApplySetIndexBuffer(const MGPIndexBuffer& record);
// ---------------------------------------------------------------------------------
// The derivation step (ARCHITECTURE.md 5.3, P2 brief D5)
// ---------------------------------------------------------------------------------
// Recomputes every PipeInputs field that is a pure function of the working
// RenderStateParameters, instead of pulling it out of GLContext a second time.
//
// The oracle is the one P1 built: MOBILEGL_PIPE_VERIFY's compare-at-read re-reads each of
// these from the live context at every backend read, so a transcription error is caught
// on the first draw that reads it - on the retrace and integration-verify LANES, which is
// where the comparator arms (MG_Config::Features.PipeVerify). A unit-test process never
// runs the config loader, so the unit oracle is a different one:
// RenderStateSpansTest.DerivationMatchesTheFrontendGetters walks every setter and
// compares all 29 derived values against the frontend getters they were transcribed from.
void MGPipeDeriveRenderStateFields(PipeInputs& inputs);
// The same derivation, SCOPED to the chunks a scatter actually moved (bit i is global
// chunk i - MGPipeGlobalChunkBitsOf{Pipeline,Dynamic}Mask widens a wire mask to it). This
// is what the applier calls, and it is why a per-frame glViewport - the D8 case whose
// whole point is that it sends dynamic chunk D0 alone - does not pay for the 8-wide blend
// loop, the 16-wide depth-range loop or the 35-arm capability switch. Every guard's chunk
// set is computed from the boundary table with MGPipeRenderStateChunkBitsCovering, so a
// boundary move cannot leave one stale, and
// RenderStateSpansTest.IncrementalChunksKeepEveryDerivedFieldInStep drives the scoped
// path against the frontend getters family by family.
void MGPipeDeriveRenderStateFieldsForChunks(PipeInputs& inputs, Uint32 globalChunkBits);
} // namespace MobileGL::MG_Pipe
+7 -1
View File
@@ -16,6 +16,12 @@
// kScreen lands in struct MGPipeScreen, every other class in struct
// MGPipeContext (plan section 4.3).
// Flags : kNone | kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot | kOptional
// kNeedsAck on a call means records of this call MAY require an ack; a
// per-record predicate decides. resource_respecify carries it for
// glBufferStorage - a real synchronous allocation, and the only entry point
// allowed a synchronous ack - and MGPipeResourceRespecifyNeedsAck(desc)
// (MGPipeTypes.h) is what says so, which is why the same call still carries
// every glBufferData without acknowledging one.
//
// RECORD NUMBERING NEVER CHURNS. Entries that are not implemented yet still occupy their
// line (plan section 11, P0: "the complete call catalogue, placeholders included"). A new
@@ -73,7 +79,7 @@
/* ---- screen: caps, resources, persistent map, fences (plan 4.4.1) ---- */ \
X(GetCaps, MGPCaps, kScreen, kReplySlot) \
X(ResourceCreate, MGPResourceDesc, kScreen, kNone) \
X(ResourceRespecify, MGPResourceDesc, kScreen, kNone) \
X(ResourceRespecify, MGPResourceDesc, kScreen, kNeedsAck) \
X(ResourceDestroy, MGPHandleOnly, kScreen, kNone) \
X(MapPersistent, MGPHandleOnly, kScreen, kReplySlot|kOptional) \
X(UnmapPersistent, MGPHandleOnly, kScreen, kOptional) \
+24 -4
View File
@@ -97,7 +97,7 @@
F(Res) F(Offset) F(Stride) F(Divisor) F(BindingIndex)
#define MGP_FIELDS_MGPVertexBuffers(F) \
F(Start) F(Count) F(ContentHash)
F(Start) F(Count) F(BaseInstance) F(ContentHash)
#define MGP_FIELDS_MGPIndexBuffer(F) \
F(Res) F(Offset) F(IndexSize)
@@ -144,8 +144,11 @@
#define MGP_FIELDS_MGPPatchState(F) \
F(Vertices) F(Outer) F(Inner)
// P2 ratcheted this block from six rows to one: RenderStateParameters retired to
// create/bind_render_state + set_dynamic_state, Pack to set_pixel_pack_state and the
// patch trio to set_patch_state. What is left is the redundant capability trip wire.
#define MGP_FIELDS_ResidualValueBlock(F) \
F(RenderState) F(Pack) F(CapabilityBits) F(PatchVertices) F(PatchOuter) F(PatchInner)
F(CapabilityBits)
#define MGP_FIELDS_MGPResidualValueState(F) \
F(Version) F(Blob)
@@ -231,7 +234,8 @@
F(Viewports) F(LineWidth) F(PointSize) F(PatchVertices) F(PatchDefaultOuterLevel) \
F(PatchDefaultInnerLevel) F(PolygonOffsetFactor) F(PolygonOffsetUnits) F(PolygonOffsetClamp) \
F(ClipOrigin) F(ClipDepthMode) F(BlendStates) F(LogicOp) F(DepthTestEnabled) F(DepthFunc) \
F(DepthMask) F(ColorMasks) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \
F(DepthMask) F(ColorMasks) F(FramebufferSrgbEnabled) F(DepthClampEnabled) \
F(TextureCubeMapSeamlessEnabled) F(ClearColor) F(ClearDepth) F(ClearStencil) F(BlendColor) \
F(DepthRanges) F(SampleCoverageValue) F(SampleCoverageInvert) F(SampleMaskValue) \
F(MinSampleShadingValue) F(StencilStates) F(CullFaceEnabled) F(CullFaceModeSetting) \
F(FrontFaceModeSetting) F(ProvokingVertexModeSetting) F(LineSmoothHint) F(PolygonSmoothHint) \
@@ -291,6 +295,21 @@
#define MGP_FIELDS_MGHostSpan(F) \
F(Ptr) F(Seg) F(Size) F(Offset)
// P3a's two vertex wire views (MGPipeValueTypes.h). They are not call payloads either: they
// are the ELEMENTS of create_vertex_elements' blob, and the comparator has to see into them
// for the same reason it sees into the value structs - a blob compared with memcmp would
// false-differ on MGPVertexAttribWire::Pad0. Divisor is deliberately not in the attribute
// list (it travels in MGPVertexBuffer) and the two Legacy* query answers are deliberately not
// on the wire at all; both absences are argued in MGPipeValueTypes.h and both are enforced
// here by gen_pipe.py's "every list names exactly its struct's direct members" rule.
#define MGP_FIELDS_MGPVertexAttribWire(F) \
F(Offset) F(Stride) F(Type) F(Size) F(Enabled) F(Normalized) F(IsInteger) F(IsLong) F(IsBgra) \
F(BindingIndex)
#define MGP_FIELDS_MGPVertexBindingPointWire(F) \
F(Offset) F(Stride) F(Divisor)
// Every payload above, in the order the comparator is generated. Keep in sync with the
// macros; gen_pipe.py reads THIS list to know what to emit.
#define MGP_VERIFY_PAYLOAD_LIST(P) \
@@ -308,6 +327,7 @@
P(MGPXfbAccounting) P(MGPStreamOutputControl) P(MGPFlush) P(MGPPresent) P(MGPSwapInterval) \
P(MGPSurfaceInfo) \
P(RenderStateParameters) P(PixelStoreParameters) P(PerBufferBlendState) P(StencilFaceState) \
P(DynamicBackendParameters) P(MGHostSpan)
P(DynamicBackendParameters) P(MGHostSpan) \
P(MGPVertexAttribWire) P(MGPVertexBindingPointWire)
// clang-format on
+121
View File
@@ -33,10 +33,131 @@ namespace MobileGL::MG_Pipe {
// A no-op unless a context is live, a verb has been filled, and `field` is in that verb
// class's may-read mask; a forwarded (sticky) field has no storage and is never copied.
void MGPipeNoteFrontendMutation(MGPipeInputField field);
// ---- the aggregate generations (P2 brief D4, ARCHITECTURE.md 5.2) ----
//
// MGP_NOTE_MUTATION answers "a backend moved a frontend value INSIDE its own verb".
// MGP_NOTE_AGGREGATE answers a different question, which is why it is a second macro
// and not an overload: "did ANY object of this class move since the last time the
// tracker looked", collapsed onto one monotonic Uint64 per class so a per-verb dirty
// walk is a handful of compares rather than a scan over 32 attributes, 16 attachments,
// 32 texture units and 84 binding points.
//
// The counters are members of the owning MG_State container, all guarded by
// MOBILEGL_PIPE_PUSH so the pull build's state objects do not change size (G1). The
// bump points sit on OBJECTS, which have no back-pointer to their state, so the macro
// goes through a free function that finds the live GLContext - the same shape, and for
// the same reason, as MGP_NOTE_MUTATION (MG_Impl/Pipe/PipeFill.cpp). It costs a global
// load on a path that has just written object state.
//
// Monotonic and never reset: the tracker widens and compares, it never subtracts.
// Over-firing is free (one extra re-push); under-firing renders stale, which is why
// every counter here is deliberately COARSER than the state it guards.
enum class MGPipeAggregate : Uint32 {
// VertexArrayState: any VAO attribute format / buffer / enable moved.
VaoAttribute = 0,
// FramebufferState: any FBO attachment or default-geometry write, or a bind.
FramebufferAttachment,
// TextureState: any texture object CONTENT moved (an upload, a dirty region).
TextureContent,
// TextureState: any texture object or sampler object PARAMETER moved.
TextureParams,
// BufferState: any buffer object contents moved.
BufferChange,
// GLContext: a glVertexAttrib* default value moved. Not one of D4 five: the bit it
// shutters (NEW_VERTEX_ATTRIB_DEFAULTS) is specified there as a ContentHash over
// all 32 CurrentVertexAttributeValues, and hashing 768 bytes on EVERY draw does not
// fit inside the T1 ceiling. The hash still decides whether to EMIT (D11 set-hash
// suppressor); this decides whether to hash at all.
VertexAttribDefault,
Count,
};
// MG_Impl/Pipe/PipeFill.cpp. A no-op unless a context is live.
void MGPipeNoteAggregate(MGPipeAggregate aggregate);
// ---- P3a: the resource family's emission points (brief D-A1) ----
//
// The seven BufferBackendOps hooks already dispatch at the GL call that causes them
// (ARCHITECTURE.md 5.1 names them as the ONE exception to push-at-validate), so their
// pipe calls are emitted from the same BufferObject dispatchers rather than from the
// validate point. That puts the emission inside MG_State, which is why these are
// DECLARED here beside the two notices and DEFINED in MG_Impl/Pipe/PipeFill.cpp: this
// header is the one MG_State already includes for exactly this, and the closure gate
// (check_include_closure.py's mutation-header probe) keeps it a declaration - reaching
// MG_Impl/Pipe/ResourceTracker.h from BufferObject.cpp would pull the client's tracker
// into the state machine that calls it.
//
// The forward declaration is the whole coupling: none of these needs the definition of
// BufferObject, and this header must not gain it.
} // namespace MobileGL::MG_Pipe
namespace MobileGL::MG_State::GLState {
class BufferObject;
}
namespace MobileGL::MG_Pipe {
// (Features.PipePush & kMGPipeSubsystemResources) != 0 && MGPipeGetResourceOps() != nullptr.
//
// BOTH HALVES MATTER. The bit is the operator's per-subsystem A/B; the table is "has a
// backend taken this family over at all". Until one has, every dispatch below falls
// through to the BufferBackendOps table it replaces and the tree behaves exactly as it
// did - which is what lets the client half land on its own.
Bool MGPipeResourceSubsystemEnabled();
// The nullable member, asked the way the frontend asks g_bufferBackendOps->ResidentSubData
// today: one backend deliberately does not implement it and the caller has a different
// path when it is absent (BufferObject::FillSubData).
Bool MGPipeResourceOpsHaveSubDataResident();
// Minted from the constructor and released from the destructor, both unconditionally in
// a push build: a handle is CLIENT state and set_vertex_buffers names it whether or not
// the resource family is switched on. The CALLS are what the predicate above gates.
void MGPipeMintResourceHandle(MG_State::GLState::BufferObject& buffer);
// In this order, and it is not negotiable (D-L): the destroy resolves the handle, and
// MGPipeSlotAllocator::Free erases the lifetimeId -> slot mapping it resolves through.
//
// RETURNS whether resource_destroy was emitted, which is the LATCH taken at this buffer's
// create and not a second reading of MGPipeResourceSubsystemEnabled(). The destructor
// needs that answer to decide whether the legacy OnDestroy still owes a call: asking the
// predicate twice pairs a create emitted under one registration with a destroy gated on
// another, and either direction leaks - a live applier record on a slot about to be
// re-handed-out, or a backend object nobody releases.
Bool MGPipeEmitResourceDestroyAndFree(MG_State::GLState::BufferObject& buffer);
// THE VERTEX-ELEMENTS CSO's DEATH, and it is BACKEND-NEUTRAL - which is the whole point.
// Before this, the only thing that ever returned a VertexElementsCso slot was DirectGLES'
// StateObjectDeathOps table; under any backend that installs none - DirectVulkan/Magma,
// which keeps its own age-reclaimed identity table on purpose - every VAO ever created
// held its slot and its ~1.3 KB applier record for the life of the process, on the shipped
// 0x1ff mask, and past 65536 slots every create_vertex_elements became a permanent
// Fatal{ProtocolCorruption}. The client mints the slot, so the client is where the death
// has to be spoken from.
//
// Takes the lifetime id and not the object for StateObjectDeathNotice.h's reason: the last
// SharedPtr has already dropped by the time this runs, and the lifetime id is what the
// slot allocator resolves the handle from. Returns whether delete_vertex_elements went
// out, i.e. whether the applier actually held a record - see the definition for why that
// is asked rather than assumed.
Bool MGPipeEmitVertexElementsDestroyAndFree(Uint64 lifetimeId);
void MGPipeEmitResourceCreate(MG_State::GLState::BufferObject& buffer);
void MGPipeEmitResourceRespecify(MG_State::GLState::BufferObject& buffer);
void MGPipeEmitResourceSubData(MG_State::GLState::BufferObject& buffer, SizeT offset, SizeT size);
void MGPipeEmitBufferSubDataResident(MG_State::GLState::BufferObject& buffer, SizeT offset,
const void* bytes, SizeT size);
void MGPipeEmitResourceFlushRange(MG_State::GLState::BufferObject& buffer, SizeT offset, SizeT size,
Uint32 accessFlags);
void MGPipeEmitResourceReadback(MG_State::GLState::BufferObject& buffer);
// Returns the coherent host pointer the resource owner donated, or null for a DECLINE -
// which is a real answer. Every call, mint or decline, is one map-persistent roundtrip.
void* MGPipeEmitMapPersistent(MG_State::GLState::BufferObject& buffer);
} // namespace MobileGL::MG_Pipe
#define MGP_NOTE_MUTATION(Field) \
::MobileGL::MG_Pipe::MGPipeNoteFrontendMutation(::MobileGL::MG_Pipe::MGPipeInputField::Field)
#define MGP_NOTE_AGGREGATE(Aggregate) \
::MobileGL::MG_Pipe::MGPipeNoteAggregate(::MobileGL::MG_Pipe::MGPipeAggregate::Aggregate)
#else
#define MGP_NOTE_MUTATION(Field) ((void)0)
#define MGP_NOTE_AGGREGATE(Aggregate) ((void)0)
#endif
#endif
+91
View File
@@ -299,6 +299,97 @@ inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] =
"SetStreamOutputTargets",
};
// P2 brief D5: the call that now SUPPLIES a field, so the residual fill loop no
// longer pulls it out of GLContext. kNone means the field is still pulled - which
// is what makes MOBILEGL_PIPE_PUSH a true per-subsystem A/B instead of a single
// switch. Rows come from Coverage.def's MGP_COVERAGE_EMITTED_LIST.
enum class MGPipeFieldEmitter : Uint8 {
kNone = 0,
BindRenderState,
BindVertexElements,
CreateRenderState,
SetDynamicState,
SetPatchState,
SetVertexAttribDefaults,
};
inline constexpr const char* kMGPipeFieldEmitterNames[] = {
"kNone",
"BindRenderState",
"BindVertexElements",
"CreateRenderState",
"SetDynamicState",
"SetPatchState",
"SetVertexAttribDefaults",
};
inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount] = {
MGPipeFieldEmitter::kNone, // GetActiveTextureUnit
MGPipeFieldEmitter::SetDynamicState, // GetBlendColor
MGPipeFieldEmitter::CreateRenderState, // GetBlendEquationIndexed
MGPipeFieldEmitter::CreateRenderState, // GetBlendFuncIndexed
MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackName
MGPipeFieldEmitter::BindVertexElements, // GetBoundVertexArray
MGPipeFieldEmitter::kNone, // GetBufferBindingSlot
MGPipeFieldEmitter::kNone, // GetBufferBindingPoint
MGPipeFieldEmitter::kNone, // GetBufferBindingPointCount
MGPipeFieldEmitter::kNone, // GetTouchedBufferBindingPointCount
MGPipeFieldEmitter::SetDynamicState, // GetClampReadColor
MGPipeFieldEmitter::SetDynamicState, // GetClearColor
MGPipeFieldEmitter::SetDynamicState, // GetClearDepth
MGPipeFieldEmitter::SetDynamicState, // GetClearStencil
MGPipeFieldEmitter::CreateRenderState, // GetColorMaskIndexed
MGPipeFieldEmitter::CreateRenderState, // GetCullFaceMode
MGPipeFieldEmitter::SetVertexAttribDefaults, // GetCurrentVertexAttribute
MGPipeFieldEmitter::CreateRenderState, // GetDepthFunc
MGPipeFieldEmitter::CreateRenderState, // GetDepthMask
MGPipeFieldEmitter::SetDynamicState, // GetDepthRangeIndexed
MGPipeFieldEmitter::kNone, // GetFramebufferBindingSlot
MGPipeFieldEmitter::kNone, // GetImageTextureBinding
MGPipeFieldEmitter::SetDynamicState, // GetLineWidth
MGPipeFieldEmitter::CreateRenderState, // GetLogicOp
MGPipeFieldEmitter::kNone, // GetMaxTouchedTextureUnit
MGPipeFieldEmitter::CreateRenderState, // GetMinSampleShadingValue
MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultInnerLevel
MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultOuterLevel
MGPipeFieldEmitter::SetPatchState, // GetPatchVertices
MGPipeFieldEmitter::BindRenderState, // GetPipelineStateVersion
MGPipeFieldEmitter::kNone, // GetPixelStoreParameters
MGPipeFieldEmitter::CreateRenderState, // GetPolygonModeFront
MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetFactor
MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetUnits
MGPipeFieldEmitter::SetDynamicState, // GetPrimitiveRestartIndex
MGPipeFieldEmitter::kNone, // GetProgramForDispatch
MGPipeFieldEmitter::kNone, // GetProgramForDraw
MGPipeFieldEmitter::kNone, // GetProgramObject
MGPipeFieldEmitter::CreateRenderState, // GetProvokingVertexMode
MGPipeFieldEmitter::CreateRenderState, // GetRenderStateParameters
MGPipeFieldEmitter::BindRenderState, // GetRenderStateParametersVersion
MGPipeFieldEmitter::kNone, // GetSamplingResolutionGeneration
MGPipeFieldEmitter::SetDynamicState, // GetScissorBox
MGPipeFieldEmitter::CreateRenderState, // GetStencilState
MGPipeFieldEmitter::kNone, // GetTextureBindGeneration
MGPipeFieldEmitter::kNone, // GetTextureContextId
MGPipeFieldEmitter::kNone, // GetTextureObject
MGPipeFieldEmitter::kNone, // GetTextureUnitObject
MGPipeFieldEmitter::kNone, // GetTransformFeedbackCapturedVertices
MGPipeFieldEmitter::kNone, // GetTransformFeedbackGeneration
MGPipeFieldEmitter::kNone, // GetTransformFeedbackPausedPrimitiveCounter
MGPipeFieldEmitter::kNone, // GetTransformFeedbackProgram
MGPipeFieldEmitter::SetDynamicState, // GetViewport
MGPipeFieldEmitter::SetDynamicState, // GetViewportIndexed
MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabled
MGPipeFieldEmitter::CreateRenderState, // IsCapabilityEnabledIndexed
MGPipeFieldEmitter::kNone, // IsTransformFeedbackActive
MGPipeFieldEmitter::kNone, // IsTransformFeedbackPaused
MGPipeFieldEmitter::kNone, // InvalidateCompileEnv
MGPipeFieldEmitter::kNone, // ValidateProgramName
MGPipeFieldEmitter::kNone, // RecordError
MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackLifetimeId
MGPipeFieldEmitter::kNone, // HasOpenTransformFeedbackSpan
};
inline constexpr SizeT kMGPipeEmittedFieldCount = 34;
struct MGPipeFilledState {
Uint64 CurrentVerbSerial;
Uint64 FilledGen[kMGPipeInputFieldCount];
+60 -34
View File
@@ -14,54 +14,80 @@
// D-B1 rejected three CSOs and demanded this table instead, so the table needs its own
// completeness trip wire: MG_Test walks every public RenderState setter and asserts that
// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test
// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from
// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are
// derived from a list that was reviewed rather than invented.
// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves
// (MG_Test/Pipe/RenderStateSpansTest.cpp).
//
// Deliberately absent, and each absence is a question P2 has to answer before the chunk
// table freezes:
// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability
// falls to "not supported currently" and IsCapabilityEnabled returns false), so six
// backend read points are constant false today. Pipeline state or dead capability?
// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today.
// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and
// are handled elsewhere in the payload path rather than in the memo word.
// P2 replaced P0's provenance with a RULE, and the rule is the only thing that decides
// membership: a member is pipeline state IF AND ONLY IF some public RenderState setter that
// calls BumpVersions() writes it. That is what makes the G7 invariant true by construction
// rather than by inspection, and it turns the subset into a strict SUPERSET of the 24
// members VulkanRenderer::ComputePipelineStateHash used to hash.
//
// The three questions P0 left open are ANSWERED here, and the answers are in this list:
// - FramebufferSrgb, DepthClamp and TextureCubeMapSeamless had NO STORAGE at all -
// SetCapability fell to "not supported currently" and IsCapabilityEnabled answered a
// compile-time false. P2 gave all three real storage in the three padding bytes between
// ColorMasks and ClearColor, and their setters call BumpVersions(), so: pipeline state.
// - ProvokingVertexModeSetting: SetProvokingVertexMode calls BumpVersions(), so pipeline.
// - FrontFaceModeSetting likewise. ClipOrigin and ClipDepthMode do NOT (SetClipControl is
// ++m_version only), so they are dynamic, in chunk D1.
//
// The complement of this list is the DYNAMIC subset - the half whose whole purpose is that
// glViewport must not mint a new CSO.
inline constexpr const char* const kMGPipePipelineStateMembers[] = {
"CullFaceEnabled",
"DepthTestEnabled",
"PolygonOffsetFillEnabled",
"RasterizerDiscardEnabled",
"ColorLogicOpEnabled",
"StencilTestEnabled",
"PrimitiveRestartEnabled",
"PrimitiveRestartFixedIndexEnabled",
"DepthMask",
"SampleShadingEnabled",
"MultisampleEnabled",
"SampleMaskEnabled",
"SampleMaskValue",
"MinSampleShadingValue",
"PatchVertices",
"PatchDefaultOuterLevel",
"PatchDefaultInnerLevel",
"PolygonModeFront",
"CullFaceModeSetting",
"DepthFunc",
"LogicOp",
"StencilStates",
"BlendStates",
"LogicOp",
"DepthTestEnabled",
"DepthFunc",
"DepthMask",
"ColorMasks",
"FramebufferSrgbEnabled",
"DepthClampEnabled",
"TextureCubeMapSeamlessEnabled",
"SampleCoverageValue",
"SampleCoverageInvert",
"SampleMaskValue",
"MinSampleShadingValue",
"StencilStates",
"CullFaceEnabled",
"CullFaceModeSetting",
"FrontFaceModeSetting",
"ProvokingVertexModeSetting",
"PolygonModeFront",
"PolygonModeBack",
"ColorLogicOpEnabled",
"DebugOutputEnabled",
"DebugOutputSynchronousEnabled",
"DitherEnabled",
"LineSmoothEnabled",
"MultisampleEnabled",
"PolygonOffsetFillEnabled",
"PolygonOffsetLineEnabled",
"PolygonOffsetPointEnabled",
"PolygonSmoothEnabled",
"PrimitiveRestartEnabled",
"PrimitiveRestartFixedIndexEnabled",
"RasterizerDiscardEnabled",
"SampleAlphaToCoverageEnabled",
"SampleAlphaToOneEnabled",
"SampleCoverageEnabled",
"SampleMaskEnabled",
"SampleShadingEnabled",
"StencilTestEnabled",
"ProgramPointSizeEnabled",
"ScissorTestEnabledMask",
};
inline constexpr SizeT kMGPipePipelineStateMemberCount = 24;
inline constexpr SizeT kMGPipePipelineStateMemberCount = 44;
static_assert(kMGPipePipelineStateMemberCount ==
sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0]));
// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets
// in C++ with offsetof rather than guessing them in python.
// Defined by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes every
// boundary in C++ with offsetof rather than guessing it in python. 7 pipeline
// chunks / 396 bytes and 8 dynamic chunks / 772 bytes, and the two halves
// partition [0, sizeof(RenderStateParameters)) exactly - asserted there.
extern const MGPStateChunk kMGPipePipelineChunks[];
extern const MGPStateChunk kMGPipeDynamicChunks[];
+17 -1
View File
@@ -113,6 +113,8 @@ inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState
inline Bool MGPipeVerify(const StencilFaceState& a, const StencilFaceState& b, const char** outField);
inline Bool MGPipeVerify(const DynamicBackendParameters& a, const DynamicBackendParameters& b, const char** outField);
inline Bool MGPipeVerify(const MGHostSpan& a, const MGHostSpan& b, const char** outField);
inline Bool MGPipeVerify(const MGPVertexAttribWire& a, const MGPVertexAttribWire& b, const char** outField);
inline Bool MGPipeVerify(const MGPVertexBindingPointWire& a, const MGPVertexBindingPointWire& b, const char** outField);
template <>
struct MGPipeHasFieldVerifier<MGPBlobRef> : std::true_type {};
@@ -252,6 +254,10 @@ template <>
struct MGPipeHasFieldVerifier<DynamicBackendParameters> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGHostSpan> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPVertexAttribWire> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPVertexBindingPointWire> : std::true_type {};
template <class T>
inline Bool MGPipeFieldEqual(const T& a, const T& b) {
@@ -643,6 +649,16 @@ inline Bool MGPipeVerify(const MGHostSpan& a, const MGHostSpan& b, const char**
return true;
}
inline Bool MGPipeVerify(const MGPVertexAttribWire& a, const MGPVertexAttribWire& b, const char** outField) {
MGP_FIELDS_MGPVertexAttribWire(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPVertexBindingPointWire& a, const MGPVertexBindingPointWire& b, const char** outField) {
MGP_FIELDS_MGPVertexBindingPointWire(MGP_VERIFY_FIELD)
return true;
}
#undef MGP_VERIFY_FIELD
inline constexpr SizeT kMGPipeVerifiedPayloadCount = 69;
inline constexpr SizeT kMGPipeVerifiedPayloadCount = 71;
+8
View File
@@ -929,3 +929,11 @@ inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size,
}
#undef MGP_WIRE_CHECK_BOUNDS
// The ResidualValueBlock layout, from PipeFields.def's
// MGP_FIELDS_ResidualValueBlock. Retiring a field without lowering
// MGL_RESIDUAL_BLOCK_SIZE is a build break, which is the point.
static_assert(offsetof(ResidualValueBlock, CapabilityBits) == 0,
"the residual block's first member must sit at offset 0");
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE,
"the residual ratchet only ever goes down");
@@ -11,6 +11,7 @@
#include <Config.h>
#include <atomic>
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL::MG_State::GLState {
namespace {
@@ -33,9 +34,31 @@ namespace MobileGL::MG_State::GLState {
BufferObject::BufferObject(Uint externalIndex)
: m_externalIndex(externalIndex), m_size(0), m_usage(BufferUsage::StaticDraw), m_isMapped(false),
m_mappingAccess(BufferMappingAccessBit::Null), m_mappedRange({0, 0}), m_ownsStagingData{} {}
m_mappingAccess(BufferMappingAccessBit::Null), m_mappedRange({0, 0}), m_ownsStagingData{} {
#if MOBILEGL_PIPE_PUSH
// P3a D-A2: a resource EXISTS before anything can name it, so resource_create is
// emitted from the constructor and carries no storage - the store is defined lazily
// by the first respecify and every backend already tolerates a resource with none.
// The handle itself is minted whatever the subsystem bitmask says, because
// set_vertex_buffers names this buffer by handle out of a different subsystem.
MG_Pipe::MGPipeMintResourceHandle(*this);
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) MG_Pipe::MGPipeEmitResourceCreate(*this);
#endif
}
BufferObject::~BufferObject() {
#if MOBILEGL_PIPE_PUSH
// P3a D-L: the buffer's death crosses as resource_destroy, which is the catalogue
// call for it - no seventh NotifyStateObjectDestroyed raiser is added, because that
// header exists for kinds that have no such call. The emit-then-free ORDER is fixed
// inside the helper and is not negotiable.
// The answer is the helper's LATCH - "was resource_create emitted for this buffer" -
// not a second reading of MGPipeResourceSubsystemEnabled(): a buffer constructed
// while a backend's table was registered and destroyed after it was unregistered has
// a pipe record to drop and no legacy backend object, and one constructed the other
// way round has the opposite, so the create's answer is the only one that pairs.
if (MG_Pipe::MGPipeEmitResourceDestroyAndFree(*this)) return;
#endif
if (m_resource.Backend() && g_bufferBackendOps && g_bufferBackendOps->OnDestroy) {
g_bufferBackendOps->OnDestroy(m_resource.ReleaseBackend());
}
@@ -43,6 +66,13 @@ namespace MobileGL::MG_State::GLState {
void BufferObject::NotifyRespecify() {
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
#if MOBILEGL_PIPE_PUSH
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
MG_Pipe::MGPipeEmitResourceRespecify(*this);
return;
}
#endif
if (g_bufferBackendOps && g_bufferBackendOps->Respecify) {
g_bufferBackendOps->Respecify(*this);
}
@@ -50,8 +80,15 @@ namespace MobileGL::MG_State::GLState {
void BufferObject::NotifySubData(SizeT offset, SizeT size) {
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
if (size == 0) return;
m_hasDefinedContent = true;
#if MOBILEGL_PIPE_PUSH
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
MG_Pipe::MGPipeEmitResourceSubData(*this, offset, size);
return;
}
#endif
if (g_bufferBackendOps && g_bufferBackendOps->SubData) {
g_bufferBackendOps->SubData(*this, offset, size);
}
@@ -59,8 +96,19 @@ namespace MobileGL::MG_State::GLState {
void BufferObject::NotifyFlushMappedRange(Range1D range, Flags<BufferMappingAccessBit> appAccess) {
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
if (range.start >= range.end) return;
m_hasDefinedContent = true;
#if MOBILEGL_PIPE_PUSH
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
static_assert(sizeof(appAccess.GetRaw()) <= sizeof(Uint32),
"MGPFlushRange::AccessFlags is a Uint32 and carries the application's "
"real Flags<BufferMappingAccessBit>, unnormalised");
MG_Pipe::MGPipeEmitResourceFlushRange(*this, range.start, range.end - range.start,
static_cast<Uint32>(appAccess.GetRaw()));
return;
}
#endif
if (g_bufferBackendOps && g_bufferBackendOps->FlushMappedRange) {
g_bufferBackendOps->FlushMappedRange(*this, range, appAccess);
}
@@ -73,6 +121,7 @@ namespace MobileGL::MG_State::GLState {
// undefined store to "has content" - that would cost the next orphaning
// respecification a full-size upload of bytes the application never wrote.
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
return;
}
m_hasDefinedContent = true;
@@ -80,6 +129,7 @@ namespace MobileGL::MG_State::GLState {
// The write already landed in coherent GPU memory; the backend has no separate
// copy to sync. Only bump the serial so cached transient slices invalidate.
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
return;
}
NotifySubData(offset, size);
@@ -183,6 +233,12 @@ namespace MobileGL::MG_State::GLState {
if (m_size < kLargeBufferAdoptBytes) return;
if (m_resource.IsGpuResident()) return;
if (m_isMapped) return;
#if MOBILEGL_PIPE_PUSH
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
if (void* base = MG_Pipe::MGPipeEmitMapPersistent(*this)) m_resource.AdoptPersistentMap(base);
return;
}
#endif
if (g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) return;
if (void* base = g_bufferBackendOps->AcquirePersistentMap(*this)) {
m_resource.AdoptPersistentMap(base);
@@ -302,6 +358,7 @@ namespace MobileGL::MG_State::GLState {
data.size, m_size);
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
}
void BufferObject::MarkGpuWritten() {
@@ -314,6 +371,17 @@ namespace MobileGL::MG_State::GLState {
// Cleared unconditionally: without a readback op the shadow can never catch up,
// and retrying on every subsequent read would only repeat the same no-op.
m_gpuWritePending = false;
#if MOBILEGL_PIPE_PUSH
if (m_size != 0 && MG_Pipe::MGPipeResourceSubsystemEnabled()) {
// The answer comes back through the reverse channel's OnBufferWriteback, which
// resolves this handle to this object and writes the shadow before the server
// bumps its mutation epoch. In monolith the whole sequence is synchronous inside
// the applier, so the caller sees the reconciled shadow on return exactly as it
// does today.
MG_Pipe::MGPipeEmitResourceReadback(*this);
return;
}
#endif
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->ReadbackFromGpu == nullptr) {
return;
}
@@ -362,10 +430,35 @@ namespace MobileGL::MG_State::GLState {
// NotifyContentWrite on a resident store only bumps the serial: the backend has no
// separate copy to sync, so no transfer op runs.
void BufferObject::LandBytesIntoResidentStore(SizeT offset, DataPtr bytes) {
#if MOBILEGL_PIPE_PUSH
// buffer_subdata_resident stays NULLABLE and stays asymmetric: one backend
// deliberately does not implement it, and the frontend checks the pipe table exactly
// as it checks the op table it replaces, so a backend without it keeps the legacy
// ordered in-place host write below.
if (bytes.size > 0 && MG_Pipe::MGPipeResourceSubsystemEnabled() &&
MG_Pipe::MGPipeResourceOpsHaveSubDataResident()) {
MG_Pipe::MGPipeEmitBufferSubDataResident(*this, offset, bytes.data, bytes.size);
m_hasDefinedContent = true;
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
m_gpuWritePending = true;
return;
}
// No resident op: the write lands in place below, after retiring the GPU writes this
// store is known to be waiting on - which is the same answer, and the same code, a
// backend with a null ResidentSubData gets today.
if (MG_Pipe::MGPipeResourceSubsystemEnabled()) {
SyncGpuWrites();
Memcpy(m_resource.Bytes() + offset, bytes.data, bytes.size);
NotifyContentWrite(offset, bytes.size);
return;
}
#endif
if (bytes.size > 0 && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
g_bufferBackendOps->ResidentSubData(*this, offset, bytes);
m_hasDefinedContent = true;
++m_changeSerial;
MGP_NOTE_AGGREGATE(BufferChange);
m_gpuWritePending = true;
return;
}
@@ -394,7 +487,16 @@ namespace MobileGL::MG_State::GLState {
// op the landing would memcpy the expansion into the mapping the loop below
// fills in place anyway, so a whole-arena clear would allocate a whole arena
// for nothing.
if (m_resource.IsGpuResident() && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
if (m_resource.IsGpuResident() &&
#if MOBILEGL_PIPE_PUSH
// The same question, asked of whichever table owns the family in this build.
(MG_Pipe::MGPipeResourceSubsystemEnabled()
? MG_Pipe::MGPipeResourceOpsHaveSubDataResident()
: (g_bufferBackendOps && g_bufferBackendOps->ResidentSubData))
#else
g_bufferBackendOps && g_bufferBackendOps->ResidentSubData
#endif
) {
Vector<Uint8> expanded(size);
if (pattern.size == 1) {
Memset(expanded.data(), *static_cast<const Uint8*>(pattern.data), size);
@@ -496,6 +598,14 @@ namespace MobileGL::MG_State::GLState {
if (m_isMapped) {
return false;
}
#if MOBILEGL_PIPE_PUSH
if (m_size != 0 && MG_Pipe::MGPipeResourceSubsystemEnabled()) {
void* pushedBase = MG_Pipe::MGPipeEmitMapPersistent(*this);
if (pushedBase == nullptr) return false;
m_resource.AdoptPersistentMap(pushedBase);
return true;
}
#endif
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) {
return false;
}
@@ -540,6 +650,16 @@ namespace MobileGL::MG_State::GLState {
// returning; AdoptPersistentMap then releases the shadow. Falls back to the
// shadow when the backend declines (returns null). Only attempted once - the
// storage is immutable and outlives unmap/remap.
#if MOBILEGL_PIPE_PUSH
if (!m_resource.IsGpuResident() && (access & BufferMappingAccessBit::Write) &&
!(access & BufferMappingAccessBit::FlushExplicit) &&
MG_Pipe::MGPipeResourceSubsystemEnabled()) {
if (void* pushedBase = MG_Pipe::MGPipeEmitMapPersistent(*this)) {
m_resource.AdoptPersistentMap(pushedBase);
}
return m_resource.Bytes() + range.start;
}
#endif
if (!m_resource.IsGpuResident() && (access & BufferMappingAccessBit::Write) &&
!(access & BufferMappingAccessBit::FlushExplicit) && g_bufferBackendOps &&
g_bufferBackendOps->AcquirePersistentMap) {
@@ -64,7 +64,19 @@ namespace MobileGL::MG_State::GLState {
Bool ValidateName(Uint index) const;
Bool ValidateBufferObject(Uint index) const;
#if MOBILEGL_PIPE_PUSH
// P2 brief D4: "did the contents of ANY buffer object move". One counter for every
// BufferObject ++m_changeSerial site, which is what NEW_VERTEX_BUFFERS /
// NEW_INDEX_BUFFER / NEW_CONST_BUFFERS / NEW_SHADER_BUFFERS / NEW_SO_TARGETS all
// shutter on in P2 - five bits over one aggregate until P3b splits them.
void NoteBufferChanged() { ++m_anyBufferChangeGeneration; }
Uint64 GetAnyBufferChangeGeneration() const { return m_anyBufferChangeGeneration; }
#endif
private:
#if MOBILEGL_PIPE_PUSH
Uint64 m_anyBufferChangeGeneration = 0;
#endif
UnorderedMap<Uint, SharedPtr<BufferObject>> m_bufferObjects;
IndexGenerator<Uint> m_indexGenerator;
Array<BindingSlot<BufferObject>, GlobalBufferTargets.size()> m_bindingSlots;
+15
View File
@@ -15,6 +15,7 @@
#include <Config.h>
#include <atomic>
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL::MG_State {
void Init() {
@@ -213,6 +214,12 @@ namespace MobileGL::MG_State {
current.intValue[component] = static_cast<Int32>(value[component]);
current.uintValue[component] = static_cast<Uint32>(value[component]);
}
#if MOBILEGL_PIPE_PUSH
// The two views above are CONVERSIONS, not bit copies, so which one was written
// is part of the value; set_vertex_attrib_defaults carries it.
m_currentVertexAttributeClasses[index] = kVertexAttribValueClassFloat;
#endif
MGP_NOTE_AGGREGATE(VertexAttribDefault);
}
void GLContext::SetCurrentVertexAttributeInt(Uint index, const Array<Int32, 4>& value) {
@@ -227,6 +234,10 @@ namespace MobileGL::MG_State {
current.floatValue[component] = static_cast<Float>(value[component]);
current.uintValue[component] = static_cast<Uint32>(value[component]);
}
#if MOBILEGL_PIPE_PUSH
m_currentVertexAttributeClasses[index] = kVertexAttribValueClassInt;
#endif
MGP_NOTE_AGGREGATE(VertexAttribDefault);
}
void GLContext::SetCurrentVertexAttributeUint(Uint index, const Array<Uint32, 4>& value) {
@@ -241,6 +252,10 @@ namespace MobileGL::MG_State {
current.floatValue[component] = static_cast<Float>(value[component]);
current.intValue[component] = static_cast<Int32>(value[component]);
}
#if MOBILEGL_PIPE_PUSH
m_currentVertexAttributeClasses[index] = kVertexAttribValueClassUint;
#endif
MGP_NOTE_AGGREGATE(VertexAttribDefault);
}
const CurrentVertexAttributeValue& GLContext::GetCurrentVertexAttribute(Uint index) const {
+79
View File
@@ -30,10 +30,26 @@ namespace MobileGL {
void Init();
namespace GLState {
#if MOBILEGL_PIPE_PUSH
// MGPAttribValue::ValueClass' encoding (MG_Pipe/MGPipeTypes.h documents the order
// "Float | Int | Uint | Double"). It lives here rather than in MG_Pipe because the
// FRONTEND is the only thing that knows which of the three views below a value was
// written through - the other two are numeric conversions of it - and MG_Pipe has
// no enum for the field yet. If package A introduces one, this becomes its alias.
inline constexpr Uint32 kVertexAttribValueClassFloat = 0;
inline constexpr Uint32 kVertexAttribValueClassInt = 1;
inline constexpr Uint32 kVertexAttribValueClassUint = 2;
#endif
struct CurrentVertexAttributeValue {
Array<Float, 4> floatValue{0.f, 0.f, 0.f, 1.f};
Array<Int32, 4> intValue{0, 0, 0, 1};
Array<Uint32, 4> uintValue{0u, 0u, 0u, 1u};
// Three scalar arrays and NOTHING ELSE. MG_Backend/MGPipe/PipeInputs.cpp
// compares this storage with one memcmp and asserts that size, so a fourth
// member here is a build break in a file P2 package B does not own. The
// written-class discriminator set_vertex_attrib_defaults needs therefore
// lives beside the array on GLContext, not inside the value.
};
// Which of the three views above a shader input of a given GLSL type consumes.
@@ -198,6 +214,64 @@ namespace MobileGL {
Uint GetBoundProgramPipelineName() const { return m_boundProgramPipeline; }
const SharedPtr<ProgramPipelineObject>& GetBoundProgramPipeline() const;
#if MOBILEGL_PIPE_PUSH
// ---- the aggregate generations (P2 brief D4) ----
//
// The bump points sit on OBJECTS - a VertexArrayObject, a TextureObject, a
// BufferObject - which have no back-pointer to the state container that owns
// them, so MGP_NOTE_AGGREGATE goes through MGPipeNoteAggregate, which finds
// the live context and lands here. This facade is the whole reason the
// objects need no back-pointer, and it is push-only so the pull build's
// GLContext is byte-identical (G1).
void NoteVaoAttributeChanged() { m_vertexArrayState.NoteAttributeChanged(); }
Uint64 GetAnyVaoAttributeGeneration() const {
return m_vertexArrayState.GetAnyAttributeGeneration();
}
void NoteFramebufferAttachmentChanged() { m_framebufferState.NoteAttachmentChanged(); }
Uint64 GetAnyFramebufferAttachmentGeneration() const {
return m_framebufferState.GetAnyAttachmentGeneration();
}
void NoteTextureContentChanged() { m_textureState.NoteTextureContentChanged(); }
Uint64 GetAnyTextureContentGeneration() const {
return m_textureState.GetAnyTextureContentGeneration();
}
void NoteTextureParamsChanged() { m_textureState.NoteTextureParamsChanged(); }
Uint64 GetAnyTextureParamsGeneration() const {
return m_textureState.GetAnyTextureParamsGeneration();
}
void NoteBufferChanged() { m_bufferState.NoteBufferChanged(); }
Uint64 GetAnyBufferChangeGeneration() const {
return m_bufferState.GetAnyBufferChangeGeneration();
}
// The sixth aggregate lives here rather than on a state container because
// the values it guards do too (m_currentVertexAttributes).
void NoteVertexAttribDefaultChanged() { ++m_anyVertexAttribDefaultGeneration; }
Uint64 GetAnyVertexAttribDefaultGeneration() const {
return m_anyVertexAttribDefaultGeneration;
}
// Which of the three views of m_currentVertexAttributes[index] the last
// glVertexAttrib* write filled DIRECTLY. The other two are NUMERIC
// conversions of it (SetCurrentVertexAttribute* below), not bit copies, so
// four words on a wire are not the value unless the class travels with them:
// glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue and 0x3FC00000 in
// floatValue. set_vertex_attrib_defaults carries this as MGPAttribValue's
// ValueClass so the applier can redo the conversion instead of memcpying one
// view into all three.
//
// It is kept BESIDE the array rather than inside CurrentVertexAttributeValue
// because that struct is mirrored into PipeInputs and compared there by a
// memcmp whose size assertion (MG_Backend/MGPipe/PipeInputs.cpp) is a file
// this package does not own - and because it need not be mirrored: the class
// only decides how to REBUILD the three views, so two writes that leave the
// three views identical rebuild identically whichever class they carried.
Uint32 GetCurrentVertexAttributeClass(Uint index) const {
return index < m_currentVertexAttributeClasses.size()
? m_currentVertexAttributeClasses[index]
: kVertexAttribValueClassFloat;
}
#endif
// RenderState
Uint GetRenderStateParametersVersion() const;
// Only the pipeline-relevant subset - see RenderState::m_pipelineStateVersion.
@@ -515,6 +589,11 @@ namespace MobileGL {
Bool m_transformFeedbackPaused = false;
GLenum m_transformFeedbackPrimitiveMode = GL_POINTS;
SharedPtr<ProgramObject> m_transformFeedbackProgram;
#if MOBILEGL_PIPE_PUSH
Uint64 m_anyVertexAttribDefaultGeneration = 0;
// Parallel to m_currentVertexAttributes; see GetCurrentVertexAttributeClass.
Array<Uint32, VertexArrayObject::MAX_VERTEX_ATTRIBS> m_currentVertexAttributeClasses{};
#endif
Uint64 m_transformFeedbackGeneration = 0;
// Source of the per-span ids above; never rolls back with an object switch.
Uint64 m_transformFeedbackNextGeneration = 0;
@@ -7,9 +7,11 @@
// End of Source File Header
#include "FramebufferObject.h"
#include "MG_State/GLState/StateObjectDeathNotice.h"
#include "MG_Util/Types.h"
#include <atomic>
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL::MG_State::GLState {
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
@@ -21,6 +23,20 @@ namespace MobileGL::MG_State::GLState {
return s_nextFramebufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
#if MOBILEGL_PIPE_PUSH
FramebufferObject::~FramebufferObject() {
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not the
// glDelete* that only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage
// that twin owns, stop being reachable. The notice carries the lifetime id
// because the object no longer exists to be passed, and because the lifetime id
// is what the client slot allocator resolves the handle from. No-op unless a
// backend registered the ops (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Framebuffer, m_lifetimeId);
}
#endif
// FramebufferAttachmentObject
FramebufferAttachmentObject::FramebufferAttachmentObject(
const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget textureUploadTarget, Int level,
@@ -190,6 +206,7 @@ namespace MobileGL::MG_State::GLState {
if (m_readBuffer == buf) return;
m_readBuffer = buf;
++m_objectVersion;
MGP_NOTE_AGGREGATE(FramebufferAttachment);
}
Uint FramebufferObject::GetExternalIndex() const {
@@ -201,6 +218,7 @@ namespace MobileGL::MG_State::GLState {
if (member == value) return; \
member = value; \
++m_objectVersion; \
MGP_NOTE_AGGREGATE(FramebufferAttachment); \
}
MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER(DefaultWidth, m_defaultWidth, Int)
@@ -213,5 +231,6 @@ namespace MobileGL::MG_State::GLState {
void FramebufferObject::BumpAttachmentVersion(FramebufferAttachmentType type) {
++m_attachmentVersions[static_cast<SizeT>(type)];
++m_objectVersion;
MGP_NOTE_AGGREGATE(FramebufferAttachment);
}
} // namespace MobileGL::MG_State::GLState
@@ -115,6 +115,12 @@ namespace MobileGL {
Array<Uint16, static_cast<SizeT>(FramebufferAttachmentType::FramebufferAttachmentTypeCount)>;
FramebufferObject(Uint externalIndex);
#if MOBILEGL_PIPE_PUSH
// P2 step e2. Out of line, and declared only where there is a notice to raise:
// in a pull build this class keeps its implicit destructor, which is what keeps
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
~FramebufferObject();
#endif
void AttachTexture(FramebufferAttachmentType type, const SharedPtr<ITextureObject>& texture,
TextureUploadTarget textureUploadTarget = TextureUploadTarget::Unknown, int level = 0,
@@ -25,7 +25,19 @@ namespace MobileGL::MG_State::GLState {
Bool ValidateName(Uint index) const;
Bool ValidateFramebufferObject(Uint index) const;
#if MOBILEGL_PIPE_PUSH
// P2 brief D4: "did the attachment set or the default geometry of ANY framebuffer
// move". It does NOT cover a BIND - a bind writes a BindingSlot, not the object - so
// MGPipeTracker pairs this counter with the bound draw framebuffer identity, which
// is one extra load and keeps the bump points on the object where they belong.
void NoteAttachmentChanged() { ++m_anyAttachmentGeneration; }
Uint64 GetAnyAttachmentGeneration() const { return m_anyAttachmentGeneration; }
#endif
private:
#if MOBILEGL_PIPE_PUSH
Uint64 m_anyAttachmentGeneration = 0;
#endif
UnorderedMap<Uint, SharedPtr<FramebufferObject>> m_framebufferObjects;
IndexGenerator<Uint> m_indexGenerator;
Array<BindingSlot<FramebufferObject>, static_cast<SizeT>(FramebufferTarget::FramebufferTargetCount)>
@@ -14,6 +14,7 @@
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
const char* kDefaultFragmentShaderSource = R"(#version 460 core
layout(location = 0) out vec4 FragColor;
@@ -28,7 +29,20 @@ namespace MobileGL::MG_State::GLState {
return s_nextProgramLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
ProgramObject::~ProgramObject() { CancelLink(); }
ProgramObject::~ProgramObject() {
CancelLink();
#if MOBILEGL_PIPE_PUSH
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not
// glDeleteProgram, which only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage that
// twin owns, stop being reachable. The notice carries the lifetime id because the
// object no longer exists to be passed, and because the lifetime id is what the client
// slot allocator resolves the handle from. No-op unless a backend registered the ops
// (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::ShaderCso, m_lifetimeId);
#endif
}
// EnsureLinkJoined() is defined inline in ProgramObject.h (see the comment there for
// why: ~1200 call sites, no LTO). Only its blocking half lives here.
@@ -317,9 +317,11 @@ namespace MobileGL {
SET_CAPABILITY(ColorLogicOp, enabled);
SET_CAPABILITY(DebugOutput, enabled);
SET_CAPABILITY(DebugOutputSynchronous, enabled);
SET_CAPABILITY(DepthClamp, enabled);
SET_CAPABILITY(DepthTest, enabled);
SET_CAPABILITY(CullFace, enabled);
SET_CAPABILITY(Dither, enabled);
SET_CAPABILITY(FramebufferSrgb, enabled);
SET_CAPABILITY(LineSmooth, enabled);
SET_CAPABILITY(Multisample, enabled);
SET_CAPABILITY(PolygonOffsetFill, enabled);
@@ -335,6 +337,7 @@ namespace MobileGL {
SET_CAPABILITY(SampleMask, enabled);
SET_CAPABILITY(SampleShading, enabled);
SET_CAPABILITY(StencilTest, enabled);
SET_CAPABILITY(TextureCubeMapSeamless, enabled);
SET_CAPABILITY(ProgramPointSize, enabled);
case CapabilityInput::Blend: {
Bool stateChanged = false;
@@ -378,7 +381,9 @@ namespace MobileGL {
++m_version;
break;
}
default: // not supported currently
// Every CapabilityInput now has storage; the arm is a backstop for a value
// outside the enum, not a silent swallow of a real glEnable.
default:
break;
}
#undef SET_CAPABILITY
@@ -392,9 +397,11 @@ namespace MobileGL {
RETURN_CAPABILITY(ColorLogicOp);
RETURN_CAPABILITY(DebugOutput);
RETURN_CAPABILITY(DebugOutputSynchronous);
RETURN_CAPABILITY(DepthClamp);
RETURN_CAPABILITY(DepthTest);
RETURN_CAPABILITY(CullFace);
RETURN_CAPABILITY(Dither);
RETURN_CAPABILITY(FramebufferSrgb);
RETURN_CAPABILITY(LineSmooth);
RETURN_CAPABILITY(Multisample);
RETURN_CAPABILITY(PolygonOffsetFill);
@@ -410,6 +417,7 @@ namespace MobileGL {
RETURN_CAPABILITY(SampleMask);
RETURN_CAPABILITY(SampleShading);
RETURN_CAPABILITY(StencilTest);
RETURN_CAPABILITY(TextureCubeMapSeamless);
RETURN_CAPABILITY(ProgramPointSize);
case CapabilityInput::Blend:
return m_parameters.BlendStates[0].Enabled;
@@ -169,11 +169,16 @@ namespace MobileGL {
// not evict a cached pipeline. Keeping one counter for both made a glViewport call
// knock the next draw off the pipeline memo AND the draw fast path.
Uint16 m_pipelineStateVersion = 0;
RenderStateParameters m_parameters;
// Value-initialised, PADDING INCLUDED. The MGPipe CSO key is a byte-range
// hash over RenderStateParameters and the residual block's trip wire is a
// byte-level compare, so indeterminate padding would make a CSO handle
// reproducible only within one context and would make the trip wire
// meaningless. Costs one .text resize of this constructor.
RenderStateParameters m_parameters{};
// Pixel Store
PixelStoreParameters m_pixelStorePackParameters;
PixelStoreParameters m_pixelStoreUnpackParameters;
PixelStoreParameters m_pixelStorePackParameters{};
PixelStoreParameters m_pixelStoreUnpackParameters{};
};
} // namespace GLState
} // namespace MG_State
@@ -8,6 +8,7 @@
#include "RenderbufferObject.h"
#include <MG_Util/Metrics/TextureMetrics.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
#include <atomic>
@@ -26,6 +27,20 @@ namespace MobileGL {
RenderbufferObject::RenderbufferObject(Uint externalIndex) : m_externalIndex(externalIndex) {}
#if MOBILEGL_PIPE_PUSH
RenderbufferObject::~RenderbufferObject() {
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not
// glDeleteRenderbuffers, which only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage that
// twin owns, stop being reachable. The notice carries the lifetime id because the
// object no longer exists to be passed, and because the lifetime id is what the client
// slot allocator resolves the handle from. No-op unless a backend registered the ops
// (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Renderbuffer, m_lifetimeId);
}
#endif
Uint RenderbufferObject::GetExternalIndex() const {
return m_externalIndex;
}
@@ -25,6 +25,12 @@ namespace MobileGL {
using TargetEnum = RenderbufferTarget;
RenderbufferObject(Uint externalIndex);
#if MOBILEGL_PIPE_PUSH
// P2 step e2. Out of line, and declared only where there is a notice to raise:
// in a pull build this class stays trivially destructible, which is what keeps
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
~RenderbufferObject();
#endif
Uint GetExternalIndex() const;
void SetInternalFormat(TextureInternalFormat format);
@@ -9,8 +9,10 @@
#include "SamplerObject.h"
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
#include <atomic>
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL {
namespace MG_State {
@@ -24,6 +26,20 @@ namespace MobileGL {
SamplerObject::SamplerObject(Uint externalIndex)
: m_externalIndex(externalIndex), m_lifetimeId(AllocateLifetimeId()) {}
#if MOBILEGL_PIPE_PUSH
SamplerObject::~SamplerObject() {
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not the
// glDelete* that only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage
// that twin owns, stop being reachable. The notice carries the lifetime id
// because the object no longer exists to be passed, and because the lifetime id
// is what the client slot allocator resolves the handle from. No-op unless a
// backend registered the ops (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::SamplerCso, m_lifetimeId);
}
#endif
void SamplerObject::BumpVersion() {
++m_version;
// Every setter early-outs on an unchanged value, so this only runs on a real
@@ -32,6 +48,9 @@ namespace MobileGL {
// bindings must never miss an invalidation, and over-invalidating on a wrap-mode
// write costs one re-resolve.
if (pGLContext) pGLContext->BumpSamplingResolutionGeneration();
// Every sampler parameter is a texture PARAMETER as far as the dirty walk is
// concerned, and BumpVersion is the one choke point every setter reaches.
MGP_NOTE_AGGREGATE(TextureParams);
}
void SamplerObject::SetWrapS(SamplerWrapMode mode) {
@@ -16,6 +16,12 @@ namespace MobileGL {
class SamplerObject {
public:
SamplerObject(Uint externalIndex);
#if MOBILEGL_PIPE_PUSH
// P2 step e2. Out of line, and declared only where there is a notice to raise:
// in a pull build this class keeps its implicit destructor, which is what keeps
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
~SamplerObject();
#endif
void SetWrapS(SamplerWrapMode mode);
void SetWrapT(SamplerWrapMode mode);
@@ -0,0 +1,62 @@
// MobileGL - MobileGL/MG_State/GLState/StateObjectDeathNotice.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/MGPipeHandles.h>
// P2 step e2, the frontend half: TELL the backend that a state object died, instead of
// leaving it to discover the death in a garbage sweep.
//
// Today the only kind that announces its own death is Buffer, through BufferBackendOps
// (BufferState/BufferObject.h) - a frontend-declared ops table that the backend fills in at
// context bring-up. This is the same shape for the other six kinds, with two differences that
// follow from what the notice is for:
//
// * it carries {kind, lifetimeId} and NOT the object, because by the time the last
// SharedPtr has dropped there is no object left to pass, and the lifetime id is exactly
// the key the client slot allocator resolves a handle from (ARCHITECTURE.md 4.2);
// * it is one entry point for every kind rather than one ops table per kind, because the
// backend's answer is the same for all six: free the slot, drop the twin.
//
// It exists only under MOBILEGL_PIPE_PUSH. A pull build has no slot allocator, no handle and
// nothing that could consume the notice, and G1 requires its symbol set to be byte-for-byte
// the pre-P2 one - so in that build this header declares nothing at all and the call sites
// compile to nothing.
//
// The pointer is written once, at backend bring-up, and read from state-object destructors.
// It is deliberately a plain pointer and not an atomic: the destructors and the bring-up run
// on the context thread, exactly as BufferBackendOps' g_bufferBackendOps does.
namespace MobileGL::MG_State::GLState {
struct StateObjectDeathOps {
// The last SharedPtr to the frontend object with this lifetime id has dropped.
// Called from the object's destructor, so the object must NOT be touched.
void (*OnDestroyed)(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) = nullptr;
};
inline const StateObjectDeathOps* g_stateObjectDeathOps = nullptr;
inline void SetStateObjectDeathOps(const StateObjectDeathOps* ops) {
g_stateObjectDeathOps = ops;
}
inline const StateObjectDeathOps* GetStateObjectDeathOps() {
return g_stateObjectDeathOps;
}
inline void NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
const StateObjectDeathOps* ops = g_stateObjectDeathOps;
if (ops == nullptr || ops->OnDestroyed == nullptr) return;
ops->OnDestroyed(kind, lifetimeId);
}
} // namespace MobileGL::MG_State::GLState
#endif // MOBILEGL_PIPE_PUSH
@@ -8,8 +8,10 @@
#include "TextureObject.h"
#include "MG_State/GLState/Core.h"
#include "MG_State/GLState/StateObjectDeathNotice.h"
#include "MG_Util/Types.h"
#include <MG_Util/Metrics/TextureMetrics.h>
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL {
namespace MG_State {
@@ -25,6 +27,20 @@ namespace MobileGL {
return s_nextTextureLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
#if MOBILEGL_PIPE_PUSH
TextureObjectBase::~TextureObjectBase() {
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not the
// glDelete* that only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage
// that twin owns, stop being reachable. The notice carries the lifetime id
// because the object no longer exists to be passed, and because the lifetime id
// is what the client slot allocator resolves the handle from. No-op unless a
// backend registered the ops (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Texture, m_lifetimeId);
}
#endif
void TextureObjectBase::BumpShapeVersion() {
++m_shapeVersion;
// Shape is what mipmap-completeness is computed from, and completeness decides
@@ -99,6 +115,7 @@ namespace MobileGL {
m_internalFormat = format;
BumpShapeVersion();
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
Uint TextureObjectBase::GetExternalIndex() const {
@@ -124,6 +141,7 @@ namespace MobileGL {
m_sampler->SetBorderColor(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
const IntVec4& TextureObjectBase::GetBorderColorI() const {
@@ -138,6 +156,7 @@ namespace MobileGL {
m_sampler->SetBorderColorI(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
const UintVec4& TextureObjectBase::GetBorderColorUI() const {
@@ -152,6 +171,7 @@ namespace MobileGL {
m_sampler->SetBorderColorUI(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
BorderColorForm TextureObjectBase::GetBorderColorForm() const {
@@ -201,6 +221,7 @@ namespace MobileGL {
break;
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
void TextureObjectBase::SetSwizzleParamRGBA(const Vec4<TextureSwizzleParam>& values) {
@@ -208,6 +229,7 @@ namespace MobileGL {
m_swizzleParams = values;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
const UintVec2& TextureObjectBase::GetLevelRange() const {
@@ -225,6 +247,7 @@ namespace MobileGL {
m_levelRange.y() = m_levelRange.x();
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
BumpShapeVersion();
}
@@ -236,6 +259,7 @@ namespace MobileGL {
m_levelRange.y() = maxLevel;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
BumpShapeVersion();
}
@@ -256,6 +280,7 @@ namespace MobileGL {
m_levelRange.y() = std::min(std::max(m_levelRange.y(), m_levelRange.x()), m_immutableLevels - 1);
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
Uint16 TextureObjectBase::GetTextureParamsVersion() const {
@@ -283,6 +308,7 @@ namespace MobileGL {
void TextureObjectBase::BumpContentVersion() {
++m_contentVersion;
MGP_NOTE_AGGREGATE(TextureContent);
}
Int TextureObjectBase::GetSamples() const {
@@ -292,6 +318,7 @@ namespace MobileGL {
void TextureObjectBase::SetSamples(Int samples) {
m_samples = samples;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
Bool TextureObjectBase::HasFixedSampleLocations() const {
@@ -301,6 +328,7 @@ namespace MobileGL {
void TextureObjectBase::SetFixedSampleLocations(Bool fixedSampleLocations) {
m_fixedSampleLocations = fixedSampleLocations;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
Uint64 TextureObjectBase::GetLifetimeId() const {
@@ -352,6 +380,7 @@ namespace MobileGL {
Bool dirty) {
if (dirty) {
++m_contentVersion;
MGP_NOTE_AGGREGATE(TextureContent);
}
m_textureStorage.MarkDirty(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, dirty);
}
@@ -363,6 +392,7 @@ namespace MobileGL {
void TextureObjectWithOneMipmap::MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel,
IntVec3 offset, IntVec3 size) {
++m_contentVersion;
MGP_NOTE_AGGREGATE(TextureContent);
m_textureStorage.MarkDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, offset,
size);
}
@@ -13,6 +13,7 @@
#include "../SamplerState/SamplerObject.h"
#include <Includes.h>
#include <MG_Util/Math/VectorTypes.h>
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL::MG_State::GLState {
// Texture objects are always SharedPtr-owned (TextureState creates every instance via
@@ -116,7 +117,15 @@ namespace MobileGL::MG_State::GLState {
class TextureObjectBase : public ITextureObject {
public:
TextureObjectBase(TextureTarget target, Uint externalIndex);
#if MOBILEGL_PIPE_PUSH
// P2 step e2. Out of line, and declared only where there is a notice to raise: in a
// pull build this stays the implicit `= default` the pre-P2 tree had, which is what
// keeps the pull build's symbol set byte-for-byte the pre-P2 one (G1). Declared on the
// BASE, so every concrete texture class - 2D, 3D, cube, buffer, view - announces once.
virtual ~TextureObjectBase();
#else
virtual ~TextureObjectBase() = default;
#endif
TextureInternalFormat GetFormat() const override;
TextureTarget GetTarget() const override;
@@ -180,6 +189,7 @@ namespace MobileGL::MG_State::GLState {
if (m_depthStencilTextureMode == mode) return;
m_depthStencilTextureMode = mode;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
}
protected:
@@ -7,6 +7,7 @@
// End of Source File Header
#include "TextureObject2DCube.h"
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL {
namespace MG_State {
@@ -49,6 +50,7 @@ namespace MobileGL {
void TextureObject2DCube::MarkStorageDirty(TextureUploadTarget uploadTarget, Uint mipmapLevel, bool dirty) {
if (dirty) {
++m_contentVersion;
MGP_NOTE_AGGREGATE(TextureContent);
}
m_textureStorage.MarkDirty(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, dirty);
}
@@ -60,6 +62,7 @@ namespace MobileGL {
void TextureObject2DCube::MarkStorageDirtyRegion(TextureUploadTarget uploadTarget, Uint mipmapLevel,
IntVec3 offset, IntVec3 size) {
++m_contentVersion;
MGP_NOTE_AGGREGATE(TextureContent);
m_textureStorage.MarkDirtyRegion(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, offset,
size);
}
@@ -140,7 +140,22 @@ namespace MobileGL::MG_State::GLState {
// again (the unit tests do exactly that between cases).
Uint64 GetContextId() const { return m_contextId; }
#if MOBILEGL_PIPE_PUSH
// P2 brief D4, the two texture aggregates. CONTENT is an upload or a dirty region;
// PARAMS is a glTexParameter or a glSamplerParameter. They are separate because
// NEW_SAMPLER_VIEWS and NEW_SAMPLERS are separate dirty bits and a Minecraft frame
// moves them at wildly different rates.
void NoteTextureContentChanged() { ++m_anyTextureContentGeneration; }
Uint64 GetAnyTextureContentGeneration() const { return m_anyTextureContentGeneration; }
void NoteTextureParamsChanged() { ++m_anyTextureParamsGeneration; }
Uint64 GetAnyTextureParamsGeneration() const { return m_anyTextureParamsGeneration; }
#endif
private:
#if MOBILEGL_PIPE_PUSH
Uint64 m_anyTextureContentGeneration = 0;
Uint64 m_anyTextureParamsGeneration = 0;
#endif
static Uint64 AllocateContextId();
const Uint64 m_contextId;
@@ -9,6 +9,7 @@
#include "VertexArrayObject.h"
#include <atomic>
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL::MG_State::GLState {
// Starts at 1 so a zero-initialized memo slot can never carry a live object's id.
@@ -37,6 +38,29 @@ namespace MobileGL::MG_State::GLState {
}
}
#if MOBILEGL_PIPE_PUSH
VertexArrayObject::~VertexArrayObject() {
// P2 step e2 / P3a C-1: ANNOUNCE the death instead of leaving the backend to discover
// it in a garbage sweep, and RETURN THE CLIENT'S OWN SLOT while doing it. This is the
// last SharedPtr to this object dropping - not the glDelete* that only marks the name
// and leaves a still-bound object very much alive - so it is the exact moment the
// backend's twin, the driver storage that twin owns, the applier's vertex-elements
// record and the {slot, gen} that names all three stop being reachable.
//
// ALL FOUR OF THOSE GO THROUGH ONE HELPER, and it is the client's rather than a
// backend's: the handle is minted by MGPipeVertexInputEmitter on every backend, so a
// death path that only exists inside a backend's death-ops table is no path at all
// under a backend that installs none - which is what DirectVulkan/Magma does on
// purpose, and what made every VAO leak a slot and a ~1.3 KB applier record for the
// life of the process on the shipped mask. The helper emits delete_vertex_elements,
// raises the notice (a no-op unless a backend registered the ops) and frees the slot,
// in that fixed order; MG_Pipe/PipeMutation.h and its definition say why each position
// is where it is. The buffer's death has exactly this shape one file over
// (BufferObject.cpp -> MGPipeEmitResourceDestroyAndFree).
MG_Pipe::MGPipeEmitVertexElementsDestroyAndFree(m_lifetimeId);
}
#endif
void VertexArrayObject::EnableAttribute(Uint index) {
if (index >= MAX_VERTEX_ATTRIBS) return;
@@ -297,18 +321,21 @@ namespace MobileGL::MG_State::GLState {
if (index >= MAX_VERTEX_ATTRIBS) return;
++m_attributeVersions[index].FormatVersion;
++m_configVersion;
MGP_NOTE_AGGREGATE(VaoAttribute);
}
void VertexArrayObject::BumpAttributeBufferVersion(Uint index) {
if (index >= MAX_VERTEX_ATTRIBS) return;
++m_attributeVersions[index].BufferVersion;
++m_configVersion;
MGP_NOTE_AGGREGATE(VaoAttribute);
}
void VertexArrayObject::BumpAttributeSwitchVersion(Uint index) {
if (index >= MAX_VERTEX_ATTRIBS) return;
++m_attributeVersions[index].SwitchVersion;
++m_configVersion;
MGP_NOTE_AGGREGATE(VaoAttribute);
}
const VertexAttributeVersion& VertexArrayObject::GetAttributeVersion(Uint index) const {
@@ -25,6 +25,12 @@ namespace MobileGL {
static constexpr int MAX_VERTEX_ATTRIB_BINDINGS = 32;
VertexArrayObject(Uint externIndex);
#if MOBILEGL_PIPE_PUSH
// P2 step e2. Out of line, and declared only where there is a notice to raise:
// in a pull build this class keeps its implicit destructor, which is what keeps
// the pull build's symbol set byte-for-byte the pre-P2 one (G1).
~VertexArrayObject();
#endif
void EnableAttribute(Uint index);
void DisableAttribute(Uint index);
@@ -100,6 +106,22 @@ namespace MobileGL {
// "any vertex-input state changed" with one compare.
Uint32 GetConfigVersion() const { return m_configVersion; }
#if MOBILEGL_PIPE_LEGACY_MEMOS
// ---- THE BACKEND'S THREE MEMOS ON THE FRONTEND OBJECT ----
//
// P2 D12.5 (ARCHITECTURE.md 9.5) retires all three: a frontend state object
// must not hold the backend's raw pointers, and under split it cannot - the
// backend is in another process and its cache entry has no address the client
// could store. Magma's handle arm keeps the same three facts in a slot-indexed
// table it owns itself (VertexInputStateFactory::VaoBackendMemos), keyed on the
// VAO's {slot, gen} and validated by the same config version, so nothing is
// recomputed more often than it was.
//
// They stay compiled under MOBILEGL_PIPE_LEGACY_MEMOS - which a PULL build
// forces ON - because that is the arm the pre-handle A/B runs, and because G1
// admits no change to the pull build. They are deleted outright with the pull
// path at P13.
//
// Backend-owned content-hash memo, valid while the config version matches
// (same idea as ProgramObject's hash memo — avoids re-hashing all
// attributes on every draw).
@@ -148,6 +170,7 @@ namespace MobileGL {
m_backendAuxMemo1 = aux1;
m_backendAuxMemoVersion = m_configVersion;
}
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
private:
void BumpAttributeFormatVersion(Uint index);
@@ -187,6 +210,10 @@ namespace MobileGL {
Array<Bool, MAX_VERTEX_ATTRIBS> m_attributeUsesBindingModel = {};
Uint32 m_configVersion = 0;
#if MOBILEGL_PIPE_LEGACY_MEMOS
// The storage behind the three accessors above; retired with them (D12.5).
// A pull build forces MOBILEGL_PIPE_LEGACY_MEMOS ON, so sizeof(this) does not
// move there and G1 sees no change.
mutable Uint64 m_backendHashMemo = 0;
mutable Uint32 m_backendHashMemoVersion = ~0u;
mutable const void* m_backendStateMemo = nullptr;
@@ -195,6 +222,7 @@ namespace MobileGL {
mutable Uint64 m_backendAuxMemo0 = 0;
mutable Uint64 m_backendAuxMemo1 = 0;
mutable Uint32 m_backendAuxMemoVersion = ~0u;
#endif // MOBILEGL_PIPE_LEGACY_MEMOS
};
} // namespace GLState
} // namespace MG_State
@@ -28,7 +28,20 @@ namespace MobileGL {
const SharedPtr<VertexArrayObject>& GetBoundVertexArray();
Vector<SharedPtr<VertexArrayObject>>& GetAllVertexArrays();
#if MOBILEGL_PIPE_PUSH
// P2 brief D4: "did the attribute configuration of ANY vertex array move".
// Bumped from every VertexArrayObject::BumpAttribute*Version through
// MGP_NOTE_AGGREGATE(VaoAttribute), which is coarser than the per-object
// m_configVersion on purpose - the tracker wants one compare, and an extra
// re-push costs a push while a missed one renders stale.
void NoteAttributeChanged() { ++m_anyVaoAttributeGeneration; }
Uint64 GetAnyAttributeGeneration() const { return m_anyVaoAttributeGeneration; }
#endif
private:
#if MOBILEGL_PIPE_PUSH
Uint64 m_anyVaoAttributeGeneration = 0;
#endif
// "Nothing bound" (an out-of-range or never-created name was bound). Distinct from
// being bound to a live slot so that a slot filled AFTER such a bind does not
// retroactively become the bound VAO.
+21 -1
View File
@@ -15,6 +15,9 @@
#include "Init.h"
#include <Config.h>
#include <MG_State/GLState/Core.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/PipeApply.h>
#endif
#include <MG_Impl/GLImpl/Buffer/GL_Buffer.h>
#include <MG_Impl/GetProcAddress.h>
@@ -1647,11 +1650,28 @@ namespace {
.ReadbackFromGpu = ZeroCopyMock_ReadbackFromGpu,
};
// These cases are BufferBackendOps dispatch tests. Since P3a a push build also installs the
// handle-shaped MGPipeResourceOps table at backend bring-up, and the frontend's push arms route
// every buffer mutation there first; the fixture therefore scopes BOTH tables, the way
// ResourceEmitTest's ApplierGuard scopes the applier, so the mock installed here is the arm
// that actually runs. The pipe-side dispatch has its own coverage in ResourceEmitTest.
struct ScopedBackendOps {
explicit ScopedBackendOps(const MG_State::GLState::BufferBackendOps* ops) {
MG_State::GLState::SetBufferBackendOps(ops);
#if MOBILEGL_PIPE_PUSH
m_savedResourceOps = MG_Pipe::MGPipeGetResourceOps();
MG_Pipe::MGPipeSetResourceOps(nullptr);
#endif
}
~ScopedBackendOps() { MG_State::GLState::SetBufferBackendOps(nullptr); }
~ScopedBackendOps() {
#if MOBILEGL_PIPE_PUSH
MG_Pipe::MGPipeSetResourceOps(m_savedResourceOps);
#endif
MG_State::GLState::SetBufferBackendOps(nullptr);
}
#if MOBILEGL_PIPE_PUSH
const MG_Pipe::MGPipeResourceOps* m_savedResourceOps = nullptr;
#endif
};
} // namespace
+121
View File
@@ -53,6 +53,127 @@ if (MSVC)
target_compile_options(PipeInputsTest PRIVATE /Zc:preprocessor)
endif()
# The four P2 suites. Their targets and this registration are the CONTRACT commit's; their
# CONTENTS belong to the packages named in each file's header, so no package after A has to
# come back to this file.
#
# RenderStateSpansTest links gtest, not gtest_main, for PipeInputsTest's reason: its applier
# trip-wire cases read the wire's line back out of a log file, so the suite needs its own
# main() to point MOBILEGL_LOG_FILE_PATH at one before anything logs. The other three need no
# main() of their own.
foreach(pipeTest TrackerTest SlotAllocatorTest CsoCacheTest)
add_executable(${pipeTest} ${pipeTest}.cpp)
target_include_directories(${pipeTest} PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/MobileGL/MG_Pipe
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(${pipeTest} PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(${pipeTest} PRIVATE /Zc:preprocessor)
endif()
endforeach()
add_executable(
RenderStateSpansTest
RenderStateSpansTest.cpp
)
target_include_directories(RenderStateSpansTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/MobileGL/MG_Pipe
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(RenderStateSpansTest PRIVATE
GTest::gtest
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(RenderStateSpansTest PRIVATE /Zc:preprocessor)
endif()
# Magma's own {slot, gen} mint and the claim rule its per-slot memo tables use
# (MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h, which is header-only). It sits here rather
# than under Backend/DirectVulkan because it needs no device, no GLFW and no Vulkan loader - and
# because that directory is registered only when ENABLE_INTEGRATION_TESTS is on, while this
# suite is the only place in the tree where a deleted `++Gen` is caught. See the file header.
add_executable(
MagmaPipeIdentityTest
MagmaPipeIdentityTest.cpp
)
target_include_directories(MagmaPipeIdentityTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/MobileGL/MG_Pipe
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(MagmaPipeIdentityTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(MagmaPipeIdentityTest PRIVATE /Zc:preprocessor)
endif()
# P3a's two suites. Their targets and this registration are the CONTRACT commit's, for the
# same reason the four P2 suites' are: their CONTENTS belong to two later packages each, and
# neither of them should have to come back to this file to add a case.
#
# They link gtest rather than gtest_main and carry their own main(), like PipeInputsTest and
# RenderStateSpansTest: the applier's bounds and protocol trip wires report through a log line
# in a shipped push build and std::abort() in a poison or verify one, so a case that drives
# one reads the line back out of a file the process names before anything logs. Deciding that
# HERE is what keeps the later packages out of this file.
foreach(pipeTest ResourceEmitTest VertexInputEmitTest)
add_executable(${pipeTest} ${pipeTest}.cpp)
target_include_directories(${pipeTest} PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/MobileGL/MG_Pipe
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(${pipeTest} PRIVATE
GTest::gtest
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(${pipeTest} PRIVATE /Zc:preprocessor)
endif()
endforeach()
include(GoogleTest)
gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
foreach(pipeTest ResourceEmitTest VertexInputEmitTest)
gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
endforeach()
gtest_discover_tests(MagmaPipeIdentityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(PipeInputsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(RenderStateSpansTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
foreach(pipeTest TrackerTest SlotAllocatorTest CsoCacheTest)
gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
endforeach()
+200
View File
@@ -0,0 +1,200 @@
// MobileGL - MobileGL/MG_Test/Pipe/CsoCacheTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The render-state CSO cache (P2 brief D7). Owned by P2 package B (p2/tracker); the file and
// its CMake registration are the contract commit's.
//
// Needs the push sources, so every case is a visible SKIP in a pull build rather than a
// vanishing test.
#include <gtest/gtest.h>
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <Config.h>
#include <MG_Impl/Pipe/CsoCache.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
#include <MG_Pipe/PipeApply.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
#if !MOBILEGL_PIPE_PUSH
// G2 REQUIRES THE PULL AND PUSH CTEST NAME SETS TO BE IDENTICAL, name for name. A
// push-only case therefore cannot be ABSENT from a pull build; it has to be there and
// SKIP, which is the shape PipeInputsTest.cpp established for the same reason. This list
// declares exactly the suite.name pairs the push build gets from the real cases below, so
// a case added on one side and forgotten on the other shows up as a ctest-name diff
// rather than as a test that silently is not there.
#define MGL_CSO_CACHE_TEST_LIST(X) \
X(CsoCacheTest, TheSameStateIsMintedOnceAndReusedForever) \
X(CsoCacheTest, LruEvictsTheOldestAndEmitsDelete) \
X(CsoCacheTest, HashCollisionDoesNotAliasTwoStates) \
X(CsoCacheTest, ContentAddressingOffMintsEveryTime) \
X(CsoCacheTest, EveryAcquireCountsItsPayloadBytes) \
X(SetHashSuppressorTest, TheFirstEmissionAlwaysGoesOutOnEverySlot) \
X(SetHashSuppressorTest, AComputedZeroIsRemappedSoItIsNeverConfusedWithNeverEmitted) \
X(SetHashSuppressorTest, SlotsAreIndependent) \
X(SetHashSuppressorTest, InvalidateMakesTheNextSetGoOutWhateverItHashesTo)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_CSO_CACHE_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
class CsoCacheTest : public ::testing::Test {
protected:
void SetUp() override {
m_savedPush = MG_Config::Features.PipePush;
MGPipeCsoCache::s_hashForTest = nullptr;
MGPipeApplierReset();
}
void TearDown() override {
MG_Config::Features.PipePush = m_savedPush;
MGPipeCsoCache::s_hashForTest = nullptr;
MGPipeApplierReset();
}
// A render state that differs from every other `seed` in a PIPELINE byte, so each one
// is a genuinely different CSO. SampleMaskValue is in pipeline chunk P2.
static RenderStateParameters PipelineState(Uint32 seed) {
RenderStateParameters params{};
params.SampleMaskValue = seed;
return params;
}
Uint64 m_savedPush = 0;
};
TEST_F(CsoCacheTest, TheSameStateIsMintedOnceAndReusedForever) {
MGPipeCsoCache cache;
Uint64 bytes = 0;
const RenderStateParameters params = PipelineState(7);
const MGPipeHandle first = cache.Acquire(params, bytes);
for (int i = 0; i < 16; ++i) EXPECT_TRUE(cache.Acquire(params, bytes) == first);
EXPECT_EQ(cache.GetCounters().Mints, 1u);
EXPECT_EQ(cache.GetCounters().Hits, 16u);
EXPECT_EQ(cache.Size(), 1u);
cache.Reset();
}
TEST_F(CsoCacheTest, LruEvictsTheOldestAndEmitsDelete) {
MGPipeCsoCache cache;
Uint64 bytes = 0;
Vector<MGPipeHandle> handles;
for (Uint32 i = 0; i < kMGPipeCsoCacheCapacity; ++i) {
handles.push_back(cache.Acquire(PipelineState(i), bytes));
}
EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity);
EXPECT_EQ(cache.GetCounters().Evictions, 0u);
// Touch entry 0 so it is no longer the oldest; entry 1 becomes the victim.
EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]);
const MGPipeHandle overflow = cache.Acquire(PipelineState(kMGPipeCsoCacheCapacity), bytes);
EXPECT_EQ(cache.Size(), kMGPipeCsoCacheCapacity);
EXPECT_EQ(cache.GetCounters().Evictions, 1u);
EXPECT_EQ(cache.GetCounters().Mints, kMGPipeCsoCacheCapacity + 1);
EXPECT_FALSE(overflow == handles[1]);
// The one that was touched survived; the evicted one has to be minted again.
EXPECT_TRUE(cache.Acquire(PipelineState(0), bytes) == handles[0]);
const MGPipeHandle reborn = cache.Acquire(PipelineState(1), bytes);
EXPECT_FALSE(reborn == handles[1]);
EXPECT_EQ(cache.GetCounters().Evictions, 2u);
cache.Reset();
}
// A 64-bit hash collision between two different render states would alias them onto one
// CSO, which is silent wrong pixels with no gate that can see it. The memcmp confirm is
// what stops it, and this is what proves the memcmp is doing something.
TEST_F(CsoCacheTest, HashCollisionDoesNotAliasTwoStates) {
MGPipeCsoCache::s_hashForTest = [](const void*) -> Uint64 { return 0x1234'5678'9abc'def0ull; };
MGPipeCsoCache cache;
Uint64 bytes = 0;
const MGPipeHandle a = cache.Acquire(PipelineState(1), bytes);
const MGPipeHandle b = cache.Acquire(PipelineState(2), bytes);
EXPECT_FALSE(a == b) << "two different render states were aliased onto one CSO";
EXPECT_EQ(cache.GetCounters().Collisions, 1u);
EXPECT_EQ(cache.GetCounters().Mints, 2u);
EXPECT_EQ(cache.GetCounters().Hits, 0u);
cache.Reset();
}
// The negative control the whole CSO design is measured against (ROADMAP.md P2). It turns
// off the PROBE and the handle reuse, not the records - otherwise it would measure a
// different design rather than this one without content addressing.
TEST_F(CsoCacheTest, ContentAddressingOffMintsEveryTime) {
MG_Config::Features.PipePush |= kMGPipeBehaviourNoCsoContentAddressing;
MGPipeCsoCache cache;
Uint64 bytes = 0;
const RenderStateParameters params = PipelineState(3);
const MGPipeHandle first = cache.Acquire(params, bytes);
const MGPipeHandle second = cache.Acquire(params, bytes);
const MGPipeHandle third = cache.Acquire(params, bytes);
EXPECT_FALSE(first == second);
EXPECT_FALSE(second == third);
EXPECT_EQ(cache.GetCounters().Mints, 3u);
EXPECT_EQ(cache.GetCounters().Hits, 0u);
cache.Reset();
}
TEST_F(CsoCacheTest, EveryAcquireCountsItsPayloadBytes) {
MGPipeCsoCache cache;
Uint64 bytes = 0;
cache.Acquire(PipelineState(11), bytes);
// A mint puts the descriptor and the whole pipeline half on the wire.
EXPECT_EQ(bytes, sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes);
const Uint64 afterMint = bytes;
cache.Acquire(PipelineState(11), bytes);
// A hit puts NOTHING on the wire: the 12-byte bind is the caller's, not the cache's.
EXPECT_EQ(bytes, afterMint);
cache.Reset();
}
// ---- the set-hash suppressor (D11) ----
TEST(SetHashSuppressorTest, TheFirstEmissionAlwaysGoesOutOnEverySlot) {
MGPipeSetHashSuppressor suppressor;
for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) {
const auto slot = static_cast<MGPipeSuppressorSlot>(i);
EXPECT_EQ(suppressor.LastEmitted(slot), 0u) << "slot " << i << " did not start at 0";
EXPECT_TRUE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " suppressed its first set";
EXPECT_FALSE(suppressor.ShouldEmit(slot, 0)) << "slot " << i << " re-emitted an unmoved set";
}
}
TEST(SetHashSuppressorTest, AComputedZeroIsRemappedSoItIsNeverConfusedWithNeverEmitted) {
MGPipeSetHashSuppressor suppressor;
const auto slot = MGPipeSuppressorSlot::SetVertexAttribDefaults;
EXPECT_TRUE(suppressor.ShouldEmit(slot, 0));
EXPECT_EQ(suppressor.LastEmitted(slot), 1u) << "a computed 0 must not read as never emitted";
EXPECT_FALSE(suppressor.ShouldEmit(slot, 0));
}
TEST(SetHashSuppressorTest, SlotsAreIndependent) {
MGPipeSetHashSuppressor suppressor;
EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42));
EXPECT_TRUE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetSamplerViews, 42));
EXPECT_FALSE(suppressor.ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, 42));
}
TEST(SetHashSuppressorTest, InvalidateMakesTheNextSetGoOutWhateverItHashesTo) {
MGPipeSetHashSuppressor suppressor;
const auto slot = MGPipeSuppressorSlot::SetShaderImages;
EXPECT_TRUE(suppressor.ShouldEmit(slot, 99));
EXPECT_FALSE(suppressor.ShouldEmit(slot, 99));
suppressor.Invalidate(slot);
EXPECT_TRUE(suppressor.ShouldEmit(slot, 99));
suppressor.InvalidateAll();
EXPECT_TRUE(suppressor.ShouldEmit(slot, 99));
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
@@ -0,0 +1,217 @@
// MobileGL - MobileGL/MG_Test/Pipe/MagmaPipeIdentityTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// Magma's {slot, gen} mint (MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h) and the claim
// rule its per-slot memo tables use, at the one point HandleRecycleScenario cannot reach: a
// REAL SLOT REUSE.
//
// WHY THIS SUITE EXISTS (fix-aba review v1, MAJOR 1). The AbaControl integration lanes defeat
// the object identity that SELECTS the slot, and that is the whole of what a same-frame pixel
// reproducer can defeat:
//
// * the mint has no death notification, so a slot returns to the free list only through
// OnFrameBoundary's age sweep (kSweepInterval 256, kRetireAgeBoundaries 1024);
// * HandleRecycleScenario issues five frame boundaries, so its replacement VAO gets a
// BRAND-NEW slot at Gen 1 and the generation never participates in a compare;
// * a real reuse needs >= 1024 idle boundaries, which 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 deleting the `++m_entries[index].Gen` in MagmaPipeIdentityTable::ClaimSlot leaves every
// arm of HandleRecycleScenario green. It reds AnIdleSlotIsRetiredAndReusedWithANewGeneration
// and AReusedSlotDoesNotServeItsPredecessorsMemo below, which is the whole point of the file.
//
// The suite lives beside SlotAllocatorTest because it asserts the same identity contract that
// file asserts for the client allocator - "Gen moves on REUSE and never on respecify" - for the
// second mint in the tree, the one Magma keeps because nothing in P2 can call the client
// allocator's Free (MagmaPipeArms.h says why). It needs no GL context, no driver and no Vulkan
// loader: MagmaPipeArms.h is header-only.
//
// Push-only, like everything it tests, so every case is a visible SKIP in a pull build rather
// than a vanishing test (G2 name parity).
#include <gtest/gtest.h>
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeHandles.h>
#if MOBILEGL_PIPE_PUSH
#include <Config.h>
#include <MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h>
#endif
using namespace MobileGL;
namespace {
#if !MOBILEGL_PIPE_PUSH
// The push build's case list, declared once so a case added on one side and forgotten on
// the other shows up as a ctest-name diff rather than as a test that silently is not there
// (the shape CsoCacheTest.cpp established).
#define MGL_MAGMA_PIPE_IDENTITY_TEST_LIST(X) \
X(MagmaPipeIdentityTest, AnIdleSlotIsRetiredAndReusedWithANewGeneration) \
X(MagmaPipeIdentityTest, AReusedSlotDoesNotServeItsPredecessorsMemo) \
X(MagmaPipeIdentityTest, TheAbaControlKnobServesTheStaleMemoAcrossAReusedSlot) \
X(MagmaPipeIdentityTest, ALiveObjectKeepsItsSlotItsGenerationAndItsMemo)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_MAGMA_PIPE_IDENTITY_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
using namespace MobileGL::MG_Backend::DirectVulkan;
using MG_Pipe::MGPipeHandle;
// What VertexInputStateFactory::VaoBackendMemos is, reduced to the two fields the claim
// rule needs: the Owner it compares, and one payload word standing in for the memo's
// contents (there, the content hash and the resolved-entry pointer). The RULE is production
// code - MagmaPipeClaimSlotMemos - not a copy of it.
struct TestMemos {
MGPipeHandle Owner = MG_Pipe::kMGPipeNullHandle;
Uint64 Payload = 0;
};
// MagmaPipeIdentityTable's sweep cadence and retirement age are private, so the number of
// boundaries needed to retire an object last used at boundary 0 is spelled out here: the
// sweep runs when (boundary % 256) == 0 and retires entries idle for more than 1024
// boundaries, so the first sweep that can retire it is boundary 1280. Every case that
// depends on this ASSERTs the retire actually happened, so a change to either constant
// fails loudly instead of silently turning these cases into "two unrelated objects".
constexpr int kBoundariesToRetireAnObjectIdleSinceTheStart = 1280;
class MagmaPipeIdentityTest : public ::testing::Test {
protected:
void SetUp() override {
m_savedKnob = MG_Config::Features.PipeHandleAbaControl;
MG_Config::Features.PipeHandleAbaControl = false;
}
void TearDown() override { MG_Config::Features.PipeHandleAbaControl = m_savedKnob; }
// Ages the mint far enough for the sweep to retire everything that has been idle since
// the start, while touching `keepAliveLifetimeId` on every boundary so that IT is never
// retired. The keep-alive is what makes the reuse non-degenerate: it holds the first
// allocatable slot, so the slot under test is not the one negative control C aliases
// everything onto (kMagmaPipeAbaControlSlotIndex).
static void AgeUntilTheSweepRetiresTheIdleSlots(MagmaPipeIdentityTable& mint,
Uint64 keepAliveLifetimeId) {
for (int i = 0; i < kBoundariesToRetireAnObjectIdleSinceTheStart; ++i) {
mint.Acquire(keepAliveLifetimeId);
mint.OnFrameBoundary();
}
}
Bool m_savedKnob = false;
};
// The precondition every case below rests on, asserted on its own so that a failure here
// reads as "the mint stopped reusing slots" rather than as a memo bug.
TEST_F(MagmaPipeIdentityTest, AnIdleSlotIsRetiredAndReusedWithANewGeneration) {
MagmaPipeIdentityTable mint("VertexElementsCso");
const MGPipeHandle keepAlive = mint.Acquire(1);
const MGPipeHandle first = mint.Acquire(2);
ASSERT_EQ(keepAlive.Slot, MG_Pipe::kMGPipeFirstAllocatableSlot);
ASSERT_NE(first.Slot, keepAlive.Slot);
ASSERT_EQ(first.Gen, 1u) << "a slot's first handout is generation 1";
ASSERT_EQ(mint.LiveCount(), 2u);
AgeUntilTheSweepRetiresTheIdleSlots(mint, 1);
ASSERT_EQ(mint.LiveCount(), 1u)
<< "the idle slot was not retired, so nothing in this file is a slot REUSE";
// The step HandleRecycleScenario cannot take. With an empty free list this would be a
// brand-new slot at Gen 1 and the generation would never participate in any compare -
// which is exactly what the scenario measures (redVao slot=2 gen=1, greenVao slot=3
// gen=1) and why it cannot catch a deleted ++Gen.
const MGPipeHandle second = mint.Acquire(3);
EXPECT_EQ(second.Slot, first.Slot) << "a retired slot must come back before the high-water mark";
EXPECT_EQ(second.Gen, first.Gen + 1u) << "a slot that changes owner must change generation";
EXPECT_FALSE(second == first);
EXPECT_EQ(mint.Count(), 2u) << "the reuse must not mint a third slot";
}
// THE CASE THE ++Gen IS LOAD-BEARING FOR. Knob off: the replacement gets the predecessor's
// SLOT, so the slot cannot be what separates them - only the generation can.
TEST_F(MagmaPipeIdentityTest, AReusedSlotDoesNotServeItsPredecessorsMemo) {
MagmaPipeIdentityTable mint("VertexElementsCso");
MagmaPipeSlotTable<TestMemos> memos;
mint.Acquire(1); // the keep-alive, so the slot under test is not slot index 0
const MGPipeHandle first = mint.Acquire(2);
MagmaPipeClaimSlotMemos(memos, first).Payload = 0xDEADull;
ASSERT_TRUE(MagmaPipeClaimSlotMemos(memos, first).Owner == first);
ASSERT_EQ(MagmaPipeClaimSlotMemos(memos, first).Payload, 0xDEADull);
AgeUntilTheSweepRetiresTheIdleSlots(mint, 1);
const MGPipeHandle second = mint.Acquire(3);
ASSERT_EQ(second.Slot, first.Slot) << "not a slot reuse, so this case would prove nothing";
// EXPECT, not ASSERT: with the generation frozen the memo assertion below is exactly what
// goes red, and a reader of the failure should see both halves rather than stop here.
EXPECT_NE(second.Gen, first.Gen);
const TestMemos& served = MagmaPipeClaimSlotMemos(memos, second);
EXPECT_TRUE(served.Owner == second) << "the entry was not claimed for its new owner";
EXPECT_EQ(served.Payload, 0ull)
<< "the replacement inherited the dead object's memo out of the SAME slot: the "
"generation is the only thing that separates {slot, gen=N} from {slot, gen=N+1}, "
"and it did not";
}
// The same shape with negative control C on, which is what makes the case above a control
// rather than a tautology: with the knob on the memo IS served across the generation.
//
// The knob is set before the first claim, as a process-wide knob is in a real run: what it
// defeats is the identity that selects the entry, so a run that stamps with it off and reads
// with it on would be reading a different entry, not an aliased one.
TEST_F(MagmaPipeIdentityTest, TheAbaControlKnobServesTheStaleMemoAcrossAReusedSlot) {
MG_Config::Features.PipeHandleAbaControl = true;
MagmaPipeIdentityTable mint("VertexElementsCso");
MagmaPipeSlotTable<TestMemos> memos;
mint.Acquire(1);
const MGPipeHandle first = mint.Acquire(2);
TestMemos& stamped = MagmaPipeClaimSlotMemos(memos, first);
stamped.Payload = 0xDEADull;
AgeUntilTheSweepRetiresTheIdleSlots(mint, 1);
const MGPipeHandle second = mint.Acquire(3);
ASSERT_EQ(second.Slot, first.Slot);
EXPECT_NE(second.Gen, first.Gen);
const TestMemos& served = MagmaPipeClaimSlotMemos(memos, second);
EXPECT_EQ(&served, &stamped) << "the control must collapse every object onto one entry";
EXPECT_EQ(served.Payload, 0xDEADull)
<< "negative control C is not defeating the claim rule any more: the replacement was "
"NOT handed its predecessor's memo, so the AbaControl lanes assert nothing";
EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(served.Owner))
<< "the control hands the entry back UNCLEARED and UNCLAIMED - it never learns whose "
"it is, which is what 'replace the identity with a constant' means";
}
// The other half of the {slot, gen} contract, and the reason a deleted ++Gen cannot be
// 'fixed' by bumping Gen on every acquisition: a live object keeps its handle across
// sweeps, so its memo survives a reconfiguration instead of being recomputed per draw.
TEST_F(MagmaPipeIdentityTest, ALiveObjectKeepsItsSlotItsGenerationAndItsMemo) {
MagmaPipeIdentityTable mint("VertexElementsCso");
MagmaPipeSlotTable<TestMemos> memos;
const MGPipeHandle handle = mint.Acquire(2);
MagmaPipeClaimSlotMemos(memos, handle).Payload = 0xBEEFull;
// Past two sweeps (256 and 512), drawn on every boundary.
for (int i = 0; i < 700; ++i) {
mint.Acquire(2);
mint.OnFrameBoundary();
}
const MGPipeHandle again = mint.Acquire(2);
EXPECT_TRUE(again == handle) << "a live object's handle moved under the age sweep";
EXPECT_EQ(MagmaPipeClaimSlotMemos(memos, again).Payload, 0xBEEFull)
<< "a live object's memo was cleared without its slot changing owner";
EXPECT_EQ(mint.Count(), 1u);
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
+167 -21
View File
@@ -110,8 +110,13 @@ TEST(PipeCatalogue, UninstalledTablesAreAllNull) {
TEST(PipeCatalogue, ResidualBlockSizeIsPinned) {
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE);
EXPECT_EQ(sizeof(ResidualValueBlock), static_cast<SizeT>(MGL_RESIDUAL_BLOCK_SIZE));
// It carries the whole of both value structs today; that is what the later stages eat.
EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters));
// P2 ate 1240 of the 1248: RenderStateParameters retired to create/bind_render_state and
// set_dynamic_state, PixelStoreParameters to set_pixel_pack_state, the patch quintet to
// set_patch_state. What is left is one Uint64 of capability bits, and it is redundant on
// purpose - the applier's trip wire compares it against the assembled block.
EXPECT_EQ(sizeof(ResidualValueBlock), 8u);
EXPECT_LT(sizeof(ResidualValueBlock), sizeof(RenderStateParameters));
EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u);
}
// P0.5 moved the value structs into MG_Pipe/MGPipeValueTypes.h. These are the runtime twins
@@ -138,13 +143,18 @@ TEST(PipeCatalogue, ValueTypeLayoutsArePinned) {
EXPECT_EQ(kMGMaxDrawBuffers, 8u);
}
// The move did not alter the carrier: the residual block is still the render-state struct,
// then the pack struct, then the 8-aligned capability word, at the offsets it had before.
// P2 ATE THE TWO VALUE STRUCTS AND THE PATCH TAIL the name still remembers, and the name
// stays because a removed test name is a gate failure of its own (G14, additions only).
// What it now pins is the other half of the same statement: the carrier is one capability
// word, at offset 0, and the members it used to carry are gone rather than merely moved -
// which is exactly what "MGL_RESIDUAL_BLOCK_SIZE only ever goes down" has to mean.
TEST(PipeCatalogue, ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail) {
EXPECT_EQ(offsetof(ResidualValueBlock, RenderState), 0u);
EXPECT_EQ(offsetof(ResidualValueBlock, Pack), sizeof(RenderStateParameters));
EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 1200u);
EXPECT_EQ(offsetof(ResidualValueBlock, PatchVertices), 1208u);
EXPECT_EQ(offsetof(ResidualValueBlock, CapabilityBits), 0u);
EXPECT_EQ(sizeof(ResidualValueBlock), sizeof(Uint64));
// The three carriers that took the retired members over.
EXPECT_EQ(sizeof(MGPPixelPackState), sizeof(PixelStoreParameters));
EXPECT_EQ(sizeof(MGPPatchState), 40u);
EXPECT_EQ(sizeof(MGPBindRenderState), 12u);
}
// G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes.
@@ -307,33 +317,52 @@ TEST(PipeCatalogue, FloatVectorsCompareBitwise) {
EXPECT_TRUE(MGPipeFieldEqual(1.5f, 1.5f));
EXPECT_FALSE(MGPipeFieldEqual(-0.f, 0.f));
// The residual carrier is one field since P2, so the nested-struct case it used to
// demonstrate is demonstrated on RenderStateParameters directly - which is where it
// actually matters now that the block travels as create/bind_render_state chunks.
ResidualValueBlock left{};
ResidualValueBlock right{};
const char* field = nullptr;
EXPECT_TRUE(MGPipeVerify(left, right, &field));
right.RenderState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor;
right.CapabilityBits = 1ull << static_cast<Uint64>(CapabilityInput::FramebufferSrgb);
EXPECT_FALSE(MGPipeVerify(left, right, &field));
EXPECT_STREQ(field, "RenderState");
EXPECT_STREQ(field, "CapabilityBits");
RenderStateParameters leftState{};
RenderStateParameters rightState{};
const char* inner = nullptr;
EXPECT_FALSE(MGPipeVerify(left.RenderState, right.RenderState, &inner));
EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner));
rightState.BlendStates[3].SrcFactorRGB = BlendFactor::DstColor;
EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner));
EXPECT_STREQ(inner, "BlendStates");
// A NaN patch level in the render state equals itself too.
right = left;
left.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
right.RenderState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
EXPECT_TRUE(MGPipeVerify(left, right, &field));
// P2's three new capability bools are members like any other, so the comparator names
// them rather than folding them into a neighbour's padding.
rightState = leftState;
rightState.FramebufferSrgbEnabled = true;
EXPECT_FALSE(MGPipeVerify(leftState, rightState, &inner));
EXPECT_STREQ(inner, "FramebufferSrgbEnabled");
// A NaN patch level equals itself too.
rightState = leftState;
leftState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
rightState.PatchDefaultOuterLevel = FloatVec4{nan, 1.f, 1.f, 1.f};
EXPECT_TRUE(MGPipeVerify(leftState, rightState, &inner));
}
// The six value structs have field lists of their own (P1 brief D8): 63 + 6 payloads, and
// the struct that used to memcmp is compared member by member.
// the struct that used to memcmp is compared member by member. P3a added the two vertex wire
// views as a seventh and eighth non-payload entry (63 + 8), for the same reason: they are the
// elements of create_vertex_elements' blob, and a memcmp over that blob would false-differ on
// MGPVertexAttribWire::Pad0.
TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
EXPECT_EQ(kMGPipeVerifiedPayloadCount, 69u);
EXPECT_EQ(kMGPipeVerifiedPayloadCount, 71u);
static_assert(MGPipeHasFieldVerifier<RenderStateParameters>::value);
static_assert(MGPipeHasFieldVerifier<PixelStoreParameters>::value);
static_assert(MGPipeHasFieldVerifier<PerBufferBlendState>::value);
static_assert(MGPipeHasFieldVerifier<StencilFaceState>::value);
static_assert(MGPipeHasFieldVerifier<DynamicBackendParameters>::value);
static_assert(MGPipeHasFieldVerifier<MGHostSpan>::value);
static_assert(MGPipeHasFieldVerifier<MGPVertexAttribWire>::value);
static_assert(MGPipeHasFieldVerifier<MGPVertexBindingPointWire>::value);
PixelStoreParameters p{};
PixelStoreParameters q{};
const char* field = nullptr;
@@ -352,9 +381,16 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
// G7 pins the member list the pipeline/dynamic split is derived from.
TEST(PipeCatalogue, PipelineSubsetMembersArePinned) {
EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u);
EXPECT_STREQ(kMGPipePipelineStateMembers[0], "CullFaceEnabled");
EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], "ColorMasks");
// 44 as of P2, in DECLARATION order. It grew from the 24 members
// ComputePipelineStateHash used to hash because the chunk table's rule is "a byte is
// pipeline state iff a setter that calls BumpVersions() writes it", and that is a strict
// superset: sample coverage, front face, provoking vertex, the scissor-test mask, the
// back polygon mode, eleven capability bools the hash never read, and the three
// capabilities P2 gave storage to.
EXPECT_EQ(kMGPipePipelineStateMemberCount, 44u);
EXPECT_STREQ(kMGPipePipelineStateMembers[0], "PatchVertices");
EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1],
"ScissorTestEnabledMask");
}
// The reverse channel is exactly ten callbacks (section 7.1).
@@ -437,3 +473,113 @@ TEST(PipeCatalogue, SubDataBufferRangeRidesInTheUnionBox) {
EXPECT_EQ(MGPipeSubDataBufferOffset(record), 0x7FFFFFFFull);
EXPECT_EQ(MGPipeSubDataBufferSize(record), 0xFFFFFFFFull);
}
// P3a, D-H1: set_vertex_buffers carries the vertex-FETCH base instance explicitly, one per
// emitted set rather than one per entry, and the header grew 16 -> 24 bytes to hold it.
//
// The size is the cheap half. The half a compiler cannot catch is the PipeFields.def row:
// MGPVertexBuffers still HAS a ContentHash and still asserts its size whether or not the
// field list names BaseInstance, and a comparator blind to the field would let a
// baseInstance-only divergence through under MOBILEGL_PIPE_VERIFY - which is the one gate
// that would otherwise have seen the suppression bug the ContentHash rule exists to prevent.
// So the field list is pinned the only way it can be: by making the comparator name it.
TEST(PipeCatalogue, VertexBufferSetCarriesAnExplicitBaseInstance) {
static_assert(sizeof(MGPVertexBuffers) == 24);
static_assert(sizeof(MGPVertexBuffer) == 32); // the per-entry struct did NOT change
EXPECT_EQ(sizeof(MGPVertexBuffers), 24u);
MGPVertexBuffers a{};
MGPVertexBuffers b{};
const char* field = nullptr;
EXPECT_TRUE(MGPipeVerify(a, b, &field));
b.Pad0 = 0x5A; // padding is not a field
EXPECT_TRUE(MGPipeVerify(a, b, &field));
b.Pad0 = 0;
b.BaseInstance = 7;
EXPECT_FALSE(MGPipeVerify(a, b, &field));
EXPECT_STREQ(field, "BaseInstance");
// The set still carries no fetch shift per entry: an entry that disagreed with its own
// header is a shape the applier would have to police, and MGPVertexBuffer's Pad0 stays
// padding rather than becoming a second copy of the same number.
MGPVertexBuffer left{};
MGPVertexBuffer right{};
right.Pad0 = 0x5A;
EXPECT_TRUE(MGPipeVerify(left, right, &field));
}
// P3a, D-G2: the two vertex wire views. They are what create_vertex_elements' blob is made
// of, so their sizes are the blob's stride and the applier's bounds arithmetic; and IsLong is
// carried SEPARATELY from Type, because a GL_DOUBLE format converted to float and a long
// format that keeps all 64 bits are different requests that a backend has to tell apart.
TEST(PipeCatalogue, VertexWireViewsAreFlatAndCarryIsLongSeparately) {
static_assert(sizeof(MGPVertexAttribWire) == 24);
static_assert(sizeof(MGPVertexBindingPointWire) == 16);
EXPECT_EQ(sizeof(MGPVertexAttribWire), 24u);
EXPECT_EQ(sizeof(MGPVertexBindingPointWire), 16u);
MGPVertexAttribWire a{};
MGPVertexAttribWire b{};
const char* field = nullptr;
EXPECT_TRUE(MGPipeVerify(a, b, &field));
b.Pad0 = 0x5A;
EXPECT_TRUE(MGPipeVerify(a, b, &field));
b.Pad0 = 0;
// Type unchanged, IsLong moved: a comparator that folded the two would miss this.
b.IsLong = 1;
EXPECT_FALSE(MGPipeVerify(a, b, &field));
EXPECT_STREQ(field, "IsLong");
MGPVertexBindingPointWire p{};
MGPVertexBindingPointWire q{};
EXPECT_TRUE(MGPipeVerify(p, q, &field));
q.Divisor = 2;
EXPECT_FALSE(MGPipeVerify(p, q, &field));
EXPECT_STREQ(field, "Divisor");
}
// P3a, D-A5: the tree's FIRST kNeedsAck, and the reason it is not a bare flag.
//
// Flags are a PER-CALL static property and resource_respecify serves both glBufferData and
// glBufferStorage. A bare kNeedsAck on the call would acknowledge every glBufferData in a
// world upload - a round trip per chunk store the moment a transport is under it. So the flag
// declares that records of this call MAY need one and MGPipeResourceRespecifyNeedsAck decides
// per record: only an immutable store, which is a real synchronous allocation.
//
// This is the negative control for a future flag that over-acks: in monolith the ack is a
// no-op, so the mistake cannot be shipped from here, and the phase where it would bite
// inherits this pin rather than the guess.
TEST(PipeCatalogue, ResourceRespecifyAcksOnlyImmutableStorage) {
Uint32 flags = 0;
#define MGP_FLAGS_OF_RESOURCE_RESPECIFY(Name, Payload, Class, Flags) \
if (std::strcmp(#Name, "ResourceRespecify") == 0) flags = static_cast<Uint32>(Flags);
MGP_CALL_LIST(MGP_FLAGS_OF_RESOURCE_RESPECIFY)
#undef MGP_FLAGS_OF_RESOURCE_RESPECIFY
EXPECT_EQ(flags & static_cast<Uint32>(kNeedsAck), static_cast<Uint32>(kNeedsAck));
// And it is the ONLY call that carries it: a second one would be a second decision, and
// this predicate answers for exactly one call.
Uint32 ackingCalls = 0;
#define MGP_COUNT_ACKING_CALLS(Name, Payload, Class, Flags) \
if ((static_cast<Uint32>(Flags) & static_cast<Uint32>(kNeedsAck)) != 0) ++ackingCalls;
MGP_CALL_LIST(MGP_COUNT_ACKING_CALLS)
#undef MGP_COUNT_ACKING_CALLS
EXPECT_EQ(ackingCalls, 1u);
// glBufferStorage: an immutable store, and the one entry point allowed a synchronous ack.
MGPResourceDesc immutable{};
immutable.Immutable = 1;
EXPECT_TRUE(MGPipeResourceRespecifyNeedsAck(immutable));
// glBufferData through the same call: never acknowledged, whatever else the descriptor
// says. The usage hint and a defined initial content are the two things a "well it looks
// synchronous" reading would key on, so both are set here on purpose.
MGPResourceDesc mutableStore{};
mutableStore.Immutable = 0;
mutableStore.Usage = 0x88E4; // GL_STATIC_DRAW, i.e. the most "final-looking" hint there is
mutableStore.HasDefinedContent = 1;
mutableStore.Width = 64u * 1024u;
EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(mutableStore));
// And the opcode did not move: a flag-word edit is not a catalogue edit.
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::ResourceRespecify), 3);
}
+25 -25
View File
@@ -190,12 +190,12 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) {
#if !MOBILEGL_PIPE_POISON
GTEST_SKIP() << "poison not compiled in (MOBILEGL_PIPE_POISON=0)";
#else
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit));
EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject));
MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit");
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
EXPECT_TRUE(Fresh(MGPipeInputField::GetTextureUnitObject));
EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit));
// The value was still copied: only the stamp is withheld.
@@ -212,7 +212,7 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) {
}
}
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
EXPECT_FALSE(Fresh(MGPipeInputField::GetActiveTextureUnit));
EXPECT_TRUE(Fresh(MGPipeInputField::GetBoundVertexArray));
EXPECT_TRUE(Fresh(MGPipeInputField::GetRenderStateParameters));
@@ -220,7 +220,7 @@ TEST_F(PipeInputsTest, OmittingOneFieldForOneVerbLeavesExactlyThatFieldStale) {
EXPECT_TRUE(Fresh(MGPipeInputField::RecordError));
// And the omission is scoped to its verb: a different verb of the same class keeps it.
MGPipeFillForVerb(MGPipeVerb::BindImageTexture);
MGPipeValidateForVerb(MGPipeVerb::BindImageTexture);
EXPECT_TRUE(Fresh(MGPipeInputField::GetActiveTextureUnit));
#endif
}
@@ -237,9 +237,9 @@ TEST_F(PipeInputsTest, ReadingAnOmittedFieldAbortsNamingTheVerb) {
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
const ChildResult r = RunInChild([] {
MGPipeSetPoisonOmission("GenerateMipmap", "GetActiveTextureUnit");
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
(void)gPipeInputs.GetRenderStateParameters(); // a filled field of the preceding draw: must not abort
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
(void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort
(void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal
::_exit(3); // reached only if the poison failed
@@ -258,9 +258,9 @@ TEST_F(PipeInputsTest, ReadingAFilledFieldCompletes) {
#else
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
const ChildResult r = RunInChild([] {
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
(void)gPipeInputs.GetRenderStateParameters();
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
(void)gPipeInputs.GetTextureUnitObject(0);
(void)gPipeInputs.GetActiveTextureUnit();
});
@@ -309,10 +309,10 @@ TEST_F(PipeInputsTest, PoisonOmitKnobArmsTheOmission) {
GTEST_SKIP() << "no fork() on this platform";
#else
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
MGPipeFillForVerb(MGPipeVerb::DrawArrays); // the parent's parse saw an empty knob
MGPipeValidateForVerb(MGPipeVerb::DrawArrays); // the parent's parse saw an empty knob
const ChildResult r = RunInChild([] {
MG_Config::Features.PipePoisonOmit = kOmissionKnob;
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
(void)gPipeInputs.GetTextureUnitObject(0); // the sibling field: filled, must not abort
(void)gPipeInputs.GetActiveTextureUnit(); // the omitted field: Fatal
::_exit(3);
@@ -334,7 +334,7 @@ TEST_F(PipeInputsTest, BadPoisonOmitKnobIsFatalNamingTheKnob) {
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
const ChildResult r = RunInChild([] {
MG_Config::Features.PipePoisonOmit = "NoSuchVerb:GetActiveTextureUnit";
MGPipeFillForVerb(MGPipeVerb::GenerateMipmap);
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
::_exit(3);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
@@ -354,7 +354,7 @@ TEST_F(PipeInputsTest, CorruptedSnapshotFieldIsNamedWithItsSerial) {
GTEST_SKIP() << "verify not compiled in (MOBILEGL_PIPE_VERIFY=OFF)";
#else
const Uint64 serialBefore = gPipeInputs.FilledState().CurrentVerbSerial;
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial;
EXPECT_EQ(serial, serialBefore + 1);
const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast<SizeT>(MGPipeVerbClass::kDraw)];
@@ -392,11 +392,11 @@ TEST_F(PipeInputsTest, MutatedFieldIsNamedAtRead) {
GTEST_SKIP() << "no fork() on this platform";
#else
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
MGPipeFillForVerb(MGPipeVerb::Clear); // the parent armed nothing: Features.PipeVerify is false here
MGPipeValidateForVerb(MGPipeVerb::Clear); // the parent armed nothing: Features.PipeVerify is false here
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1; // the child's DrawArrays fill
const ChildResult r = RunInChild([] {
MG_Config::Features.PipeVerify = true;
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
const Float boundary = gPipeInputs.GetLineWidth(); // boundary == live: completes
(void)gPipeInputs.GetRenderStateParameters();
MG_State::pGLContext->SetLineWidth(boundary + 1.0f);
@@ -426,12 +426,12 @@ TEST_F(PipeInputsTest, VerifyCorruptKnobNamesTheFieldAtEntry) {
GTEST_SKIP() << "no fork() on this platform";
#else
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
MGPipeFillForVerb(MGPipeVerb::Clear);
MGPipeValidateForVerb(MGPipeVerb::Clear);
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1;
const ChildResult r = RunInChild([] {
MG_Config::Features.PipeVerify = true;
MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters";
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
::_exit(3);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
@@ -455,7 +455,7 @@ TEST_F(PipeInputsTest, BadVerifyCorruptKnobIsFatalNamingTheKnob) {
const ChildResult r = RunInChild([] {
MG_Config::Features.PipeVerify = true;
MG_Config::Features.PipeVerifyCorrupt = "NoSuchField";
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
::_exit(3);
});
ASSERT_TRUE(DiedOfAbort(r)) << DescribeStatus(r) << "\n" << r.Log;
@@ -479,14 +479,14 @@ TEST_F(PipeInputsTest, VerifyFatalOffLogsTheDivergenceAndContinues) {
GTEST_SKIP() << "no fork() on this platform";
#else
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
MGPipeFillForVerb(MGPipeVerb::Clear);
MGPipeValidateForVerb(MGPipeVerb::Clear);
const Uint64 serial = gPipeInputs.FilledState().CurrentVerbSerial + 1;
const ChildResult r = RunInChild([] {
MG_Config::Features.PipeVerify = true;
MG_Config::Features.PipeVerifyFatal = false;
MG_Config::Features.PipeVerifyCorrupt = "GetRenderStateParameters";
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeFillForVerb(MGPipeVerb::DrawElements);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawElements);
std::exit(0);
});
ASSERT_TRUE(ExitedWith(r, 0)) << DescribeStatus(r) << "\n" << r.Log;
@@ -511,7 +511,7 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) {
#else
for (SizeT v = 0; v < kMGPipeVerbCount; ++v) {
const auto verb = static_cast<MGPipeVerb>(v);
MGPipeFillForVerb(verb);
MGPipeValidateForVerb(verb);
const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast<SizeT>(kMGPipeVerbClass[v])];
for (SizeT f = 0; f < kMGPipeInputFieldCount; ++f) {
const auto field = static_cast<MGPipeInputField>(f);
@@ -532,7 +532,7 @@ TEST_F(PipeInputsTest, EveryVerbFillsItsClassAndNothingElse) {
// Without the notice the two reads below differ and the pushed value is the stale one.
TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbRefreshesThePushedField) {
auto& ctx = *MG_State::pGLContext;
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
ASSERT_EQ(gPipeInputs.GetSamplingResolutionGeneration(), ctx.GetSamplingResolutionGeneration());
ASSERT_EQ(gPipeInputs.GetTextureBindGeneration(), ctx.GetTextureBindGeneration());
ASSERT_EQ(gPipeInputs.GetMaxTouchedTextureUnit(), ctx.GetMaxTouchedTextureUnit());
@@ -566,7 +566,7 @@ TEST_F(PipeInputsTest, TheMutationNoticeRefreshesTheValueButNotTheStamp) {
#else
auto& ctx = *MG_State::pGLContext;
MGPipeSetPoisonOmission("DrawArrays", "GetSamplingResolutionGeneration");
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration));
ctx.BumpSamplingResolutionGeneration();
EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration))
@@ -575,7 +575,7 @@ TEST_F(PipeInputsTest, TheMutationNoticeRefreshesTheValueButNotTheStamp) {
// FenceSync is a kQuery verb: its mask holds no texture field at all, so the notice must
// leave the generation unfilled and a read of it Fatal{UnmigratedPipeInput}.
MGPipeSetPoisonOmission(nullptr, nullptr);
MGPipeFillForVerb(MGPipeVerb::FenceSync);
MGPipeValidateForVerb(MGPipeVerb::FenceSync);
ASSERT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration));
ctx.BumpSamplingResolutionGeneration();
EXPECT_FALSE(Fresh(MGPipeInputField::GetSamplingResolutionGeneration))
@@ -598,7 +598,7 @@ TEST_F(PipeInputsTest, AFrontendMutationInsideAVerbDoesNotDivergeAtRead) {
ASSERT_FALSE(g_logPath.empty()) << "main() did not set MOBILEGL_LOG_FILE_PATH";
const ChildResult r = RunInChild([] {
MG_Config::Features.PipeVerify = true;
MGPipeFillForVerb(MGPipeVerb::DrawArrays);
MGPipeValidateForVerb(MGPipeVerb::DrawArrays);
(void)gPipeInputs.GetSamplingResolutionGeneration(); // boundary == live: completes
auto& ctx = *MG_State::pGLContext;
const Uint64 before = ctx.GetSamplingResolutionGeneration();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
// MobileGL - MobileGL/MG_Test/Pipe/SlotAllocatorTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The client slot allocator's IDENTITY CONTRACT (P2 brief C.0 c3). Everything Track H keys
// off - Espryt's six slot tables and Magma's VaoDrawMemo - is only as sound as these five
// statements, and each of them is the answer to a bug the {slot, gen} pair exists to close:
//
// GenMovesOnlyOnSlotReuse - a respecify must NOT move the generation, or every
// glBufferData would invalidate every memo; a REUSE
// must, or a stale handle would address its successor.
// FreedSlotComesBackBeforeHighWater - slots stay DENSE, which is what lets the server's
// object table be an array rather than a hash map.
// SlotZeroIsNeverHandedOut - {0, 0} is the null handle for every kind and {0, 1}
// is the default framebuffer.
// LifetimeIdSurvivesARecycledAddress - the frontend key is the lifetime id, never a heap
// address and never a GL name, so an ABA on either
// cannot reproduce a handle. This is the ABA
// HandleRecycleScenario reproduces end to end.
// CompositeShaderBandIsNeverHandedOut- the top 1/16 of the ShaderCso slot space belongs to
// the program-pipeline composite resolver.
//
// Needs the push sources (SlotAllocator.cpp is compiled only under MOBILEGL_PIPE_PUSH), so
// every case is a visible SKIP in a pull build rather than a vanishing test, and the five
// names are the same five in every build.
#include <gtest/gtest.h>
#include <cstdint>
#include <memory>
#include <unordered_map>
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
// Slot 0 is reserved for every kind - null, and the default framebuffer for kind
// Framebuffer - so the first allocatable slot is 1 in every build, pull included.
TEST(SlotAllocator, ReservedHandlesAreWhatMGPipeHandlesSaysTheyAre) {
EXPECT_EQ(kMGPipeFirstAllocatableSlot, 1u);
EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle));
EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer));
EXPECT_EQ(kMGPipeDefaultFramebuffer.Slot, 0u);
}
TEST(SlotAllocator, GenMovesOnlyOnSlotReuse) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
#else
MGPipeSlotAllocator allocator;
const MGPipeHandle first = allocator.Allocate(MGPipeKind::Buffer);
const MGPipeHandle second = allocator.Allocate(MGPipeKind::Buffer);
EXPECT_EQ(first.Gen, 0u) << "a slot's FIRST handout is generation 0";
EXPECT_EQ(second.Gen, 0u);
EXPECT_NE(first.Slot, second.Slot);
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, first));
// A respecify is not an event here at all: nothing in the allocator's interface can
// move a live handle's generation, which is the contract "gen increments only when a
// slot is REUSED, never on a respecify" stated as an absence.
EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen);
allocator.Free(MGPipeKind::Buffer, first);
EXPECT_FALSE(allocator.IsLive(MGPipeKind::Buffer, first));
// The bump happens on the NEXT handout, not on the free, so a slot that is freed and
// never reused keeps its generation - and a double free cannot skip one.
EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen);
allocator.Free(MGPipeKind::Buffer, first);
EXPECT_EQ(allocator.GenOfSlot(MGPipeKind::Buffer, first.Slot), first.Gen);
const MGPipeHandle reused = allocator.Allocate(MGPipeKind::Buffer);
EXPECT_EQ(reused.Slot, first.Slot) << "the free list did not hand the slot back";
EXPECT_NE(reused.Gen, first.Gen) << "a REUSED slot must carry a new generation";
EXPECT_EQ(reused.Gen, first.Gen + 1);
// THE POINT OF THE GENERATION: the dead handle is not the live one, it does not
// validate, and it cannot free the slot its successor now owns.
EXPECT_FALSE(first == reused);
EXPECT_FALSE(allocator.IsLive(MGPipeKind::Buffer, first));
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused));
allocator.Free(MGPipeKind::Buffer, first);
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused)) << "a stale handle freed a live slot";
// Kinds are independent slot spaces: a Buffer slot 1 and a Texture slot 1 are
// different objects, and freeing one must not touch the other.
const MGPipeHandle texture = allocator.Allocate(MGPipeKind::Texture);
EXPECT_EQ(texture.Slot, kMGPipeFirstAllocatableSlot);
EXPECT_EQ(texture.Gen, 0u);
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Texture, texture));
EXPECT_TRUE(allocator.IsLive(MGPipeKind::Buffer, reused));
#endif
}
TEST(SlotAllocator, FreedSlotComesBackBeforeHighWaterGrows) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
#else
MGPipeSlotAllocator allocator;
MGPipeHandle handles[8];
for (MGPipeHandle& handle : handles) handle = allocator.Allocate(MGPipeKind::Framebuffer);
const Uint32 highWater = allocator.HighWater(MGPipeKind::Framebuffer);
EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 8u);
EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 0u);
allocator.Free(MGPipeKind::Framebuffer, handles[2]);
allocator.Free(MGPipeKind::Framebuffer, handles[5]);
EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 6u);
EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 2u);
// Density is the whole reason the server's object table can be an array: the two
// freed slots have to come back before a ninth is minted.
const MGPipeHandle a = allocator.Allocate(MGPipeKind::Framebuffer);
const MGPipeHandle b = allocator.Allocate(MGPipeKind::Framebuffer);
EXPECT_EQ(allocator.HighWater(MGPipeKind::Framebuffer), highWater) << "the high-water mark grew with two "
"slots waiting on the free list";
EXPECT_TRUE((a.Slot == handles[2].Slot && b.Slot == handles[5].Slot) ||
(a.Slot == handles[5].Slot && b.Slot == handles[2].Slot))
<< "the reused slots are not the two that were freed";
// Only now does the mark move.
const MGPipeHandle fresh = allocator.Allocate(MGPipeKind::Framebuffer);
EXPECT_GT(allocator.HighWater(MGPipeKind::Framebuffer), highWater);
EXPECT_EQ(fresh.Gen, 0u) << "a slot handed out for the FIRST time is generation 0";
allocator.Reset();
EXPECT_EQ(allocator.HighWater(MGPipeKind::Framebuffer), 0u);
EXPECT_EQ(allocator.LiveCount(MGPipeKind::Framebuffer), 0u);
EXPECT_EQ(allocator.FreeCount(MGPipeKind::Framebuffer), 0u);
#endif
}
TEST(SlotAllocator, SlotZeroIsNeverHandedOut) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
#else
MGPipeSlotAllocator allocator;
for (SizeT kindIndex = 1; kindIndex < MGPipeSlotAllocator::kKindCount; ++kindIndex) {
const MGPipeKind kind = static_cast<MGPipeKind>(kindIndex);
for (int i = 0; i < 4; ++i) {
const MGPipeHandle handle = allocator.Allocate(kind);
EXPECT_GE(handle.Slot, kMGPipeFirstAllocatableSlot)
<< "kind " << kindIndex << " handed out the reserved slot";
EXPECT_FALSE(MGPipeHandleIsNull(handle));
// {0, 1} is the default framebuffer and must never be minted either.
EXPECT_FALSE(handle == kMGPipeDefaultFramebuffer);
allocator.Free(kind, handle);
}
}
// Freeing a slot never puts 0 on the free list, so a churned kind still starts at 1.
const MGPipeHandle again = allocator.Allocate(MGPipeKind::Buffer);
EXPECT_EQ(again.Slot, kMGPipeFirstAllocatableSlot);
#endif
}
TEST(SlotAllocator, LifetimeIdSurvivesARecycledAddress) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
#else
using MG_State::GLState::VertexArrayObject;
// The allocation must actually happen: C++ permits eliding a new/delete pair, and an
// elided one would let two objects share an address for reasons that have nothing to
// do with the allocator. Publishing every pointer through a volatile sink keeps the
// pairs (MG_Test/State/ObjectLifetimeIdTest.cpp's trick, and the same one
// HandleRecycleScenario uses to reproduce the ABA through public GL).
static void* volatile addressSink = nullptr;
MGPipeSlotAllocator allocator;
std::unordered_map<std::uintptr_t, MGPipeHandle> handleAtAddress;
int reuseCount = 0;
for (int attempt = 0; attempt < 64; ++attempt) {
auto object = std::make_unique<VertexArrayObject>(0u);
addressSink = object.get();
const auto address = reinterpret_cast<std::uintptr_t>(object.get());
const Uint64 lifetimeId = object->GetLifetimeId();
// Acquire is the ordinary client path: find by lifetime id, allocate on a miss.
const MGPipeHandle handle = allocator.Acquire(MGPipeKind::VertexElementsCso, lifetimeId);
EXPECT_FALSE(MGPipeHandleIsNull(handle));
// Asking again with the same live object must answer the SAME handle - that is
// what makes the map an identity rather than a counter.
EXPECT_TRUE(allocator.Acquire(MGPipeKind::VertexElementsCso, lifetimeId) == handle);
EXPECT_EQ(allocator.LifetimeIdOfSlot(MGPipeKind::VertexElementsCso, handle.Slot), lifetimeId);
const auto previous = handleAtAddress.find(address);
if (previous != handleAtAddress.end()) {
++reuseCount;
// THE ABA. The heap handed the same address back, and the handle must still
// be a different one - either a different slot, or the same slot with a new
// generation. If this ever held, an address-keyed memo would serve the dead
// object's entry to the live one, which is the bug Track H removes.
EXPECT_FALSE(previous->second == handle)
<< "a recycled heap address reproduced handle {slot=" << handle.Slot << ", gen=" << handle.Gen
<< "}";
}
handleAtAddress[address] = handle;
// The object dies; the client's death notification frees the slot.
allocator.Free(MGPipeKind::VertexElementsCso, handle);
EXPECT_FALSE(allocator.IsLive(MGPipeKind::VertexElementsCso, handle));
// And the lifetime id stops resolving, so a late lookup cannot resurrect it.
EXPECT_TRUE(MGPipeHandleIsNull(allocator.FindByLifetimeId(MGPipeKind::VertexElementsCso, lifetimeId)));
}
// Whether the heap repeats an address is the machine's business, not the allocator's,
// so the count is RECORDED and the case does not depend on it: the arm below proves
// the same property without waiting for luck, and it proves a STRICTLY STRONGER form
// of it. Acquire never sees an address at all (SlotAllocator.h) - it sees a lifetime
// id - so the sharpest possible ABA is not "the same address came back" but "the same
// LIFETIME ID came back", which is the key the map is actually built on. MG_State
// never reissues one, so this can only be built by hand; if even that cannot
// reproduce a handle, no recycled address can either.
RecordProperty("address_reuses_observed", reuseCount);
MGPipeSlotAllocator sharp;
const Uint64 repeatedLifetimeId = 0x5eed'0000'0000'0001ull;
const MGPipeHandle first = sharp.Acquire(MGPipeKind::VertexElementsCso, repeatedLifetimeId);
EXPECT_FALSE(MGPipeHandleIsNull(first));
sharp.Free(MGPipeKind::VertexElementsCso, first);
const MGPipeHandle second = sharp.Acquire(MGPipeKind::VertexElementsCso, repeatedLifetimeId);
EXPECT_FALSE(second == first)
<< "re-acquiring the SAME lifetime id after a free reproduced handle {slot=" << first.Slot
<< ", gen=" << first.Gen << "}; an address-keyed or name-keyed memo would then serve the dead "
"object's entry to the live one";
EXPECT_EQ(second.Slot, first.Slot) << "the freed slot was not the one handed back";
EXPECT_EQ(second.Gen, first.Gen + 1) << "a reused slot must carry a new generation";
// And the dead handle stays dead, which is what makes the ABA detectable rather than
// merely unlikely.
EXPECT_FALSE(sharp.IsLive(MGPipeKind::VertexElementsCso, first));
EXPECT_TRUE(sharp.IsLive(MGPipeKind::VertexElementsCso, second));
#endif
}
TEST(SlotAllocator, CompositeShaderBandIsNeverHandedOut) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "push not compiled in (MOBILEGL_PIPE_PUSH=OFF)";
#else
MGPipeSlotAllocator allocator;
// Ordinary programs walk the low slots and never enter the band.
for (int i = 0; i < 8; ++i) {
const MGPipeHandle handle = allocator.Allocate(MGPipeKind::ShaderCso);
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(handle.Slot));
}
// Every other kind is unaffected: the band is a ShaderCso rule, not a global one.
{
MGPipeSlotAllocator plain;
for (Uint32 i = 0; i < 4; ++i) {
const MGPipeHandle handle = plain.Allocate(MGPipeKind::Buffer);
EXPECT_EQ(handle.Slot, kMGPipeFirstAllocatableSlot + i);
}
}
// ONLY THE EXHAUSTION ARM needs the DEBUG skip, and it is placed here so the two arms
// above run in every build. Walking the ShaderCso slot space up to the band is what
// proves the band is held back, and reaching the band's edge trips the allocator's
// own "slot space is exhausted" MOBILEGL_ASSERT - which is live, and correctly so, in
// a DEBUG build. The claim is checked in the INFO builds every gate runs.
#if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG
GTEST_SKIP() << "asserts are live in a DEBUG build and only the exhaustion arm trips one on purpose";
#else
// Walk the whole space up to the band. The last handout below the base must be the
// slot immediately under it, and the next call must refuse rather than step in - a
// composite handle minted by the ordinary allocator would collide with one the
// program-pipeline resolver mints for a different object entirely.
MGPipeHandle last = kMGPipeNullHandle;
while (allocator.HighWater(MGPipeKind::ShaderCso) < kMGPipeShaderCsoCompositeSlotBase) {
last = allocator.Allocate(MGPipeKind::ShaderCso);
ASSERT_FALSE(MGPipeIsCompositeShaderSlot(last.Slot))
<< "the ordinary allocator entered the composite band at slot " << last.Slot;
}
EXPECT_EQ(last.Slot, kMGPipeShaderCsoCompositeSlotBase - 1);
EXPECT_TRUE(MGPipeHandleIsNull(allocator.Allocate(MGPipeKind::ShaderCso)))
<< "the allocator handed out a composite-band slot instead of refusing";
#endif // the DEBUG guard on the exhaustion arm
#endif // MOBILEGL_PIPE_PUSH
}
} // namespace
+917
View File
@@ -0,0 +1,917 @@
// MobileGL - MobileGL/MG_Test/Pipe/TrackerTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The frontend state tracker: the dirty walk, the aggregate generations, the per-bit fire
// counters (P2 brief D4). Owned by P2 package B (p2/tracker); the file and its CMake
// registration are the contract commit's.
//
// Needs the push sources, so every case is a visible SKIP in a pull build rather than a
// vanishing test - the shape PipeInputsTest.cpp established.
#include <gtest/gtest.h>
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <Config.h>
#include <MG_Impl/Pipe/CsoCache.h>
#include <MG_Impl/Pipe/PipeFill.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/Tracker.h>
#include <MG_Impl/Pipe/VertexInputEmit.h>
#include <MG_Pipe/MGPipeRenderStateSpans.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <cstring>
#include <limits>
#include <string>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
// The subsystem bitmask the tracker dispatches on is allocated in every build.
TEST(Tracker, SubsystemBitsDoNotOverlapTheBehaviourBit) {
EXPECT_EQ(kMGPipeSubsystemsMigratedAtP2 & kMGPipeBehaviourNoCsoContentAddressing, 0ull);
}
#if !MOBILEGL_PIPE_PUSH
// G2 REQUIRES THE PULL AND PUSH CTEST NAME SETS TO BE IDENTICAL, name for name. A
// push-only case therefore cannot be ABSENT from a pull build; it has to be there and
// SKIP, which is the shape PipeInputsTest.cpp established for the same reason. This list
// declares exactly the suite.name pairs the push build gets from the real cases below, so
// a case added on one side and forgotten on the other shows up as a ctest-name diff
// rather than as a test that silently is not there.
#define MGL_TRACKER_TEST_LIST(X) \
X(TrackerAggregates, EveryAggregateStartsAtZero) \
X(TrackerAggregates, AVertexArrayAttributeMovesOnlyTheVaoAggregate) \
X(TrackerAggregates, AFramebufferObjectWriteMovesOnlyTheFramebufferAggregate) \
X(TrackerAggregates, AFramebufferDefaultSetterMovesOnlyTheFramebufferAggregate) \
X(TrackerAggregates, ATextureContentWriteMovesOnlyTheContentAggregate) \
X(TrackerAggregates, ATextureParameterMovesOnlyTheParamsAggregate) \
X(TrackerAggregates, ASamplerParameterMovesOnlyTheParamsAggregate) \
X(TrackerAggregates, ABufferRespecifyMovesOnlyTheBufferAggregate) \
X(TrackerAggregates, AVertexAttribDefaultMovesOnlyItsOwnAggregate) \
X(TrackerAggregates, ANoteWithoutALiveContextIsANoOp) \
X(TrackerWalk, EveryBitHasAName) \
X(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) \
X(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) \
X(TrackerWalk, SteadyStateEmitsNothing) \
X(TrackerWalk, BlendToggleReusesTwoCsos) \
X(TrackerWalk, ViewportDoesNotMintACso) \
X(TrackerWalk, WrapAroundRePushesButNeverMisses) \
X(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) \
X(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) \
X(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) \
X(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) \
X(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) \
X(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) \
X(TrackerWalk, ABaseInstanceSurvivesTheFirstWalkOnAFreshContext) \
X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \
X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \
X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \
X(TrackerAttribPayload, TheSameNumbersWrittenThroughADifferentClassAreADifferentValue) \
X(TrackerShippedEmitter, ABlendToggleThroughTheValidatePointMintsTwoCsos) \
X(TrackerShippedEmitter, TheSteadyStateThroughTheValidatePointEmitsNothing) \
X(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) \
X(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) \
X(TrackerShippedEmitter, AClipDistanceEnableReArmsTheResidualBlock) \
X(TrackerShippedEmitter, AFreshContextRepublishesEveryVertexAttributeDefault) \
X(TrackerShippedEmitter, AFreshContextResetsTheApplierWithTheRenderStateSubsystemOff) \
X(TrackerShippedEmitter, ABaseInstancedDrawAfterAMakeCurrentPublishesItsOwnBaseInstance)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_TRACKER_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::TextureObjectBase;
using MobileGL::TextureTarget;
constexpr SizeT kAggregateCount = static_cast<SizeT>(MGPipeAggregate::Count);
// A live frontend context for the bump points to find, restored on the way out so the
// cases stay independent (PipeInputsTest's idiom).
class TrackerAggregates : public ::testing::Test {
protected:
void SetUp() override {
m_previous = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
}
void TearDown() override { MG_State::pGLContext = Move(m_previous); }
static GLContext& Ctx() { return *MG_State::pGLContext; }
struct Snapshot {
Uint64 Values[kAggregateCount];
Uint64 operator[](MGPipeAggregate a) const { return Values[static_cast<SizeT>(a)]; }
};
static Snapshot Snap() {
GLContext& c = Ctx();
Snapshot s{};
s.Values[static_cast<SizeT>(MGPipeAggregate::VaoAttribute)] = c.GetAnyVaoAttributeGeneration();
s.Values[static_cast<SizeT>(MGPipeAggregate::FramebufferAttachment)] =
c.GetAnyFramebufferAttachmentGeneration();
s.Values[static_cast<SizeT>(MGPipeAggregate::TextureContent)] = c.GetAnyTextureContentGeneration();
s.Values[static_cast<SizeT>(MGPipeAggregate::TextureParams)] = c.GetAnyTextureParamsGeneration();
s.Values[static_cast<SizeT>(MGPipeAggregate::BufferChange)] = c.GetAnyBufferChangeGeneration();
s.Values[static_cast<SizeT>(MGPipeAggregate::VertexAttribDefault)] =
c.GetAnyVertexAttribDefaultGeneration();
return s;
}
// The whole contract of an aggregate generation in one assertion: the bump point
// moved ITS counter and moved NO other. The second half is what stops a bump point
// being wired to the wrong aggregate, which would over-fire one dirty bit and
// under-fire another - and under-firing is the direction that renders stale.
static void ExpectOnly(MGPipeAggregate moved, const Snapshot& before, const Snapshot& after) {
for (SizeT i = 0; i < kAggregateCount; ++i) {
const auto which = static_cast<MGPipeAggregate>(i);
if (which == moved) {
EXPECT_GT(after.Values[i], before.Values[i]) << "aggregate " << i << " did not move";
} else {
EXPECT_EQ(after.Values[i], before.Values[i]) << "aggregate " << i << " moved and must not";
}
}
}
UniquePtr<GLContext> m_previous;
};
TEST_F(TrackerAggregates, EveryAggregateStartsAtZero) {
const Snapshot s = Snap();
for (SizeT i = 0; i < kAggregateCount; ++i) EXPECT_EQ(s.Values[i], 0ull);
}
TEST_F(TrackerAggregates, AVertexArrayAttributeMovesOnlyTheVaoAggregate) {
const auto& vao = Ctx().CreateVertexArrayObject(1);
ASSERT_TRUE(vao != nullptr);
const Snapshot before = Snap();
vao->EnableAttribute(3);
ExpectOnly(MGPipeAggregate::VaoAttribute, before, Snap());
}
TEST_F(TrackerAggregates, AFramebufferObjectWriteMovesOnlyTheFramebufferAggregate) {
const auto& fbo = Ctx().CreateFramebufferObject(1);
ASSERT_TRUE(fbo != nullptr);
fbo->SetReadBuffer(FramebufferAttachmentType::Color0);
const Snapshot before = Snap();
fbo->SetReadBuffer(FramebufferAttachmentType::Color1);
ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap());
}
TEST_F(TrackerAggregates, AFramebufferDefaultSetterMovesOnlyTheFramebufferAggregate) {
const auto& fbo = Ctx().CreateFramebufferObject(2);
ASSERT_TRUE(fbo != nullptr);
const Snapshot before = Snap();
// The five MOBILEGL_DEFINE_FRAMEBUFFER_DEFAULT_SETTER bodies are one macro, so the
// bump statement inside it has to carry its own line continuation or the macro
// silently swallows the next line. This case is what says it did not.
fbo->SetDefaultWidth(64);
ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap());
}
TEST_F(TrackerAggregates, ATextureContentWriteMovesOnlyTheContentAggregate) {
const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D);
ASSERT_TRUE(tex != nullptr);
const Snapshot before = Snap();
static_cast<TextureObjectBase*>(tex.get())->BumpContentVersion();
ExpectOnly(MGPipeAggregate::TextureContent, before, Snap());
}
TEST_F(TrackerAggregates, ATextureParameterMovesOnlyTheParamsAggregate) {
const auto& tex = Ctx().CreateTextureObject(2, TextureTarget::Texture2D);
ASSERT_TRUE(tex != nullptr);
const Snapshot before = Snap();
tex->SetMaxLevel(4);
ExpectOnly(MGPipeAggregate::TextureParams, before, Snap());
}
TEST_F(TrackerAggregates, ASamplerParameterMovesOnlyTheParamsAggregate) {
const auto& sampler = Ctx().CreateSamplerObject(1);
ASSERT_TRUE(sampler != nullptr);
const Snapshot before = Snap();
sampler->SetWrapS(MobileGL::SamplerWrapMode::ClampToEdge);
ExpectOnly(MGPipeAggregate::TextureParams, before, Snap());
}
TEST_F(TrackerAggregates, ABufferRespecifyMovesOnlyTheBufferAggregate) {
const auto& buffer = Ctx().CreateBufferObject(1);
ASSERT_TRUE(buffer != nullptr);
const Snapshot before = Snap();
buffer->Respecify(64, nullptr);
ExpectOnly(MGPipeAggregate::BufferChange, before, Snap());
}
TEST_F(TrackerAggregates, AVertexAttribDefaultMovesOnlyItsOwnAggregate) {
const Snapshot before = Snap();
Ctx().SetCurrentVertexAttributeFloat(2, Array<Float, 4>{1.0f, 2.0f, 3.0f, 4.0f});
ExpectOnly(MGPipeAggregate::VertexAttribDefault, before, Snap());
}
TEST_F(TrackerAggregates, ANoteWithoutALiveContextIsANoOp) {
UniquePtr<GLContext> held = Move(MG_State::pGLContext);
MGP_NOTE_AGGREGATE(BufferChange); // must not dereference a null context
MG_State::pGLContext = Move(held);
SUCCEED();
}
// ===================================================================================
// The dirty walk itself, and the render-state emission it drives (P2 brief D4, D6, D7)
// ===================================================================================
//
// These drive the tracker and the cache DIRECTLY rather than through
// MGPipeValidateForVerb. That is deliberate: MGPipeValidateForVerb reaches the library's
// one process-wide tracker, and a unit test that asserts on a shared singleton is a test
// that fails when ctest runs the suite in parallel. The emission logic these reproduce is
// three lines long and is the same three lines the validate point runs.
// Resetting the applier without resetting the process-wide cache and tracker would leave
// the next bind_render_state naming a CSO the applier no longer has - it asserts and
// returns, leaving m_renderState unwritten. The three are one state, so they are reset
// together, here and in TrackerShippedEmitter.
// P3a adds two more pieces to that one state. MGPipeApplierReset is a MAKE-CURRENT and
// deliberately keeps the object records now, so a fixture that means "this applier is
// going away" has to say the other verb as well (PipeApply.h); and the vertex-input
// emitter's latches say "this handle has already published this configuration" about an
// applier that is about to be empty, so a bind would be suppressed against a record that
// is no longer there.
void ResetTheServerSideSingletons() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
MGPipeCsoCacheInstance().Reset();
MGPipeCsoCacheInstance().ResetCounters();
MGPipeTrackerInstance().Reset();
MGPipeSetHashSuppressorInstance().InvalidateAll();
MGPipeVertexInputEmitterInstance().Reset();
MGPipeVertexInputEmitterInstance().ResetCounters();
}
class TrackerWalk : public ::testing::Test {
protected:
void SetUp() override {
m_previous = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
m_savedPush = MG_Config::Features.PipePush;
ResetTheServerSideSingletons();
}
void TearDown() override {
MG_Config::Features.PipePush = m_savedPush;
ResetTheServerSideSingletons();
MG_State::pGLContext = Move(m_previous);
}
static GLContext& Ctx() { return *MG_State::pGLContext; }
// What MGPipeValidateForVerb's step 3 does, minus the PipeStats plumbing: acquire a
// CSO when the pipeline version moved, and compute the dynamic chunk mask when
// m_version moved.
Uint32 Walk(MGPipeVerbClass verbClass = MGPipeVerbClass::kDraw) {
const Uint32 dirty = m_tracker.Update(Ctx(), verbClass);
const RenderStateParameters& live = Ctx().GetRenderStateParameters();
m_lastDynamicMask = 0;
if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) {
m_lastCso = m_cache.Acquire(live, m_payloadBytes);
++m_binds;
}
if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) {
m_lastDynamicMask = m_tracker.FreshlyPrimed()
? ~0u
: MGPipeDynamicChunksThatMoved(live, m_tracker.Staged());
}
if (dirty & (MGPipeDirtyBit(MGPipeDirty::NewPipelineState) |
MGPipeDirtyBit(MGPipeDirty::NewRenderState))) {
m_tracker.Staged() = live;
}
return dirty;
}
MGPipeTracker m_tracker;
MGPipeCsoCache m_cache;
MGPipeHandle m_lastCso = kMGPipeNullHandle;
Uint32 m_lastDynamicMask = 0;
Uint64 m_payloadBytes = 0;
Uint64 m_binds = 0;
Uint64 m_savedPush = 0;
UniquePtr<GLContext> m_previous;
};
TEST_F(TrackerWalk, EveryBitHasAName) {
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
ASSERT_NE(kMGPipeDirtyNames[i], nullptr);
EXPECT_EQ(std::string(kMGPipeDirtyNames[i]).rfind("NEW_", 0), 0u);
}
}
// The five P2 emits for each name their own subsystem; the rest name none, which is what
// makes MOBILEGL_PIPE_PUSH a per-subsystem A/B instead of one switch.
// THE NAME IS P2's AND IT STAYS. A test name is never removed (only added), so this case
// keeps the name it was born with and follows the phase constant instead of a literal
// five: what it has always asserted is "a bit names a subsystem if and only if this build
// emits a call for it", which is the property the emission gate and the residual-fill
// skip both rest on. P3a took the vertex-input family over, so the set it compares
// against is now kMGPipeDirtyEmittedAtP3a - and a bit that gained an arm without gaining
// an emitter, or the reverse, still fails here.
TEST_F(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) {
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
const auto bit = static_cast<MGPipeDirty>(i);
const Bool emitted = (kMGPipeDirtyEmittedAtP3a & MGPipeDirtyBit(bit)) != 0;
EXPECT_EQ(MGPipeSubsystemForDirty(bit) != 0, emitted) << kMGPipeDirtyNames[i];
}
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), kMGPipeSubsystemRenderState);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack), kMGPipeSubsystemPixelPack);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), kMGPipeSubsystemPatchState);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults),
kMGPipeSubsystemVertexAttribDefaults);
// P3a's three, one subsystem: an operator who clears bit 8 gets the whole legacy
// vertex-input arm rather than two thirds of it.
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexElements), kMGPipeSubsystemVertexInput);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexBuffers), kMGPipeSubsystemVertexInput);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewIndexBuffer), kMGPipeSubsystemVertexInput);
}
TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) {
const Uint32 dirty = Walk();
EXPECT_TRUE(m_tracker.FreshlyPrimed());
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
EXPECT_NE(dirty & (Uint32{1} << static_cast<Uint32>(i)), 0u)
<< kMGPipeDirtyNames[i] << " did not fire on a fresh context";
}
}
// The whole point of a validate-point tracker: two identical draws in a row cost two
// Uint16 compares and emit nothing at all.
TEST_F(TrackerWalk, SteadyStateEmitsNothing) {
Walk();
const Uint64 mintsAfterFirst = m_cache.GetCounters().Mints;
const Uint64 bindsAfterFirst = m_binds;
for (int i = 0; i < 8; ++i) EXPECT_EQ(Walk(), 0u) << "walk " << i << " fired with nothing moved";
EXPECT_EQ(m_cache.GetCounters().Mints, mintsAfterFirst);
EXPECT_EQ(m_binds, bindsAfterFirst);
}
// The Blaze3D shape ARCHITECTURE.md 5.1 names as the reason push happens at validate and
// not in the setter: enable / draw / disable / draw forever mints exactly TWO CSOs and
// reuses them for every toggle after that.
TEST_F(TrackerWalk, BlendToggleReusesTwoCsos) {
constexpr int kToggles = 32;
Walk(); // prime
// The priming walk already minted and cached the blend-DISABLED state, so the cache
// starts empty here or the count below would be one short of the shape it describes.
m_cache.Reset();
m_cache.ResetCounters();
m_binds = 0;
for (int i = 0; i < kToggles; ++i) {
Ctx().SetCapability(CapabilityInput::Blend, true);
Walk();
Ctx().SetCapability(CapabilityInput::Blend, false);
Walk();
}
EXPECT_EQ(m_cache.GetCounters().Mints, 2u)
<< "a two-state ping-pong must mint two CSOs and then never mint again";
EXPECT_EQ(m_binds, static_cast<Uint64>(2 * kToggles));
EXPECT_EQ(m_cache.GetCounters().Hits, static_cast<Uint64>(2 * kToggles - 2));
EXPECT_EQ(m_cache.Size(), 2u);
m_cache.Reset();
}
// The regression RenderState.h records: a glViewport must not evict a cached pipeline.
// It mints nothing and its payload is one dynamic chunk - D0, the viewports - and not the
// other seven.
TEST_F(TrackerWalk, ViewportDoesNotMintACso) {
Walk(); // prime
m_cache.ResetCounters();
for (Int i = 1; i <= 16; ++i) {
Ctx().SetViewport(IntVec4(0, 0, 64 + i, 48 + i));
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState), 0u);
EXPECT_EQ(dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u)
<< "glViewport moved the PIPELINE version";
EXPECT_EQ(m_lastDynamicMask, 1u) << "glViewport sent something other than chunk D0";
}
EXPECT_EQ(m_cache.GetCounters().Mints, 0u);
EXPECT_EQ(m_binds, 1u) << "only the priming walk may bind";
m_cache.Reset();
}
// RenderState's two shutters are Uint16 and the tracker widens them in its OWN state,
// never in MG_State. A wrap must cost an extra re-push at worst and never a missed one.
TEST_F(TrackerWalk, WrapAroundRePushesButNeverMisses) {
Walk(); // prime
constexpr int kMoves = 70000; // past 65535 with room to spare
Uint64 fired = 0;
for (int i = 0; i < kMoves; ++i) {
// Never the default 1.0f: a setter that early-outs on an unchanged value would
// not move m_version, and the first iteration would then be a false miss.
Ctx().SetLineWidth((i & 1) ? 2.0f : 3.0f);
if (Walk() & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) ++fired;
}
EXPECT_EQ(fired, static_cast<Uint64>(kMoves))
<< "a Uint16 wrap swallowed a render-state change";
m_cache.Reset();
}
// The one 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.
// ARCHITECTURE.md 13.2 names under-firing as the dangerous direction, and this is the
// first test of it.
TEST_F(TrackerWalk, AggregateGenerationCatchesABoundTextureMoving) {
const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D);
ASSERT_TRUE(tex != nullptr);
Walk(); // prime
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u);
static_cast<TextureObjectBase*>(tex.get())->BumpContentVersion();
EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u)
<< "a bound texture's content moved and NEW_SAMPLER_VIEWS did not fire";
// and it settles again, so the bit is a shutter and not a stuck flag
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u);
m_cache.Reset();
}
// A NaN outer level is a legal glPatchParameterfv value and has to compare equal to
// itself, which float equality does not do and a byte compare does.
TEST_F(TrackerWalk, ANaNPatchLevelEqualsItselfAndDoesNotFireForever) {
Walk(); // prime
Ctx().SetPatchDefaultOuterLevel(
FloatVec4(std::numeric_limits<Float>::quiet_NaN(), 1.0f, 1.0f, 1.0f));
EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u);
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPatchState), 0u)
<< "a NaN patch level re-fired against itself";
m_cache.Reset();
}
TEST_F(TrackerWalk, ThePixelPackShutterIsAByteCompareOfThePackHalfOnly) {
Walk(); // prime
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u);
Ctx().SetPixelStoreParam(PixelStoreParam::PackAlignment, 8);
EXPECT_NE(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u);
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u);
// The UNPACK half has no carrier at all, so it must not move the pack shutter.
Ctx().SetPixelStoreParam(PixelStoreParam::UnpackAlignment, 8);
EXPECT_EQ(Walk() & MGPipeDirtyBit(MGPipeDirty::NewPixelPack), 0u)
<< "an unpack write moved the PACK shutter";
m_cache.Reset();
}
TEST_F(TrackerWalk, TheFireTalliesOnlyRunWhilePipeStatsIsOn) {
// PipeStats is off in a unit-test process, which is the state the ROADMAP rule about
// hot-path instrumentation cares about: the walk must cost nothing extra there.
ASSERT_FALSE(MG_Util::PipeStats::Enabled());
Walk();
Ctx().SetLineWidth(3.0f);
Walk();
EXPECT_EQ(m_tracker.WalkCount(), 0u);
EXPECT_EQ(m_tracker.FireCount(MGPipeDirty::NewRenderState), 0u);
m_cache.Reset();
}
// ===================================================================================
// P3a D-I: bit 10's narrowed shutter
// ===================================================================================
//
// NEW_INDEX_BUFFER used to be MixShutter(the whole buffer-CONTENT aggregate, the VAO
// identity), so it fired on any buffer write anywhere - a glBufferSubData into a texture
// upload staging buffer re-published the index binding. It now reads the bound VAO's own
// element-slot version and the identity of whatever is bound to it.
//
// THIS IS THE ONE CASE THE OLD SHUTTER COULD NOT PASS, which is why it is here rather
// than in the narrowing commit's prose.
TEST_F(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) {
const SharedPtr<MG_State::GLState::BufferObject> indices = Ctx().CreateBufferObject(1);
indices->Respecify(64, nullptr);
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(indices);
Walk();
Walk();
ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
<< "the steady state must be quiet before the interesting half of this case";
// An entirely unrelated buffer's contents move. Nothing about the element binding
// changed, so the bit must stay down.
const SharedPtr<MG_State::GLState::BufferObject> unrelated = Ctx().CreateBufferObject(2);
unrelated->Respecify(4096, nullptr);
Array<Uint8, 16> bytes{};
unrelated->UploadSubData(DataPtr{bytes.data(), bytes.size()}, 0);
ASSERT_NE(Ctx().GetAnyBufferChangeGeneration(), 0u) << "the buffer aggregate did move";
Walk();
EXPECT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
<< "NEW_INDEX_BUFFER fired on a write to a buffer that is not the element binding";
// And the control, on the same tracker: the binding itself moving DOES fire it, so
// the quiet above is a narrowing and not a dead bit.
const SharedPtr<MG_State::GLState::BufferObject> other = Ctx().CreateBufferObject(3);
other->Respecify(64, nullptr);
Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot().Bind(other);
Walk();
EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
<< "NEW_INDEX_BUFFER did not fire when the element binding changed";
m_cache.Reset();
}
// The slot version is a WRAPPING Uint16 that BindingSlot bumps only on a real change, so
// it is widened at this boundary - and the bound object's lifetime id joins it because
// identity is what closes the wrap hole. 65536 binds later the version reads the same
// number it did at the start; if that number were the whole shutter, a binding that had
// moved onto a DIFFERENT buffer would read as unchanged and the draw would fetch indices
// from the previous one.
TEST_F(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) {
const SharedPtr<MG_State::GLState::BufferObject> objects[3] = {
Ctx().CreateBufferObject(1), Ctx().CreateBufferObject(2), Ctx().CreateBufferObject(3)};
for (const auto& object : objects) object->Respecify(64, nullptr);
auto& slot = Ctx().GetBoundVertexArray()->GetIndexBufferBindingSlot();
slot.Bind(objects[0]);
Walk();
Walk();
ASSERT_EQ(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u);
const Uint16 versionAtStart = slot.GetVersion();
// Drive the Uint16 all the way round WITHOUT the tracker looking, which is exactly
// the window a wrap needs: every bind between two walks is invisible to it.
//
// THREE buffers, not two, and that is the whole construction: BindingSlot bumps its
// version only on a real change, so strictly alternating between two objects makes
// the version and the bound object share a parity - 65536 changes always land back on
// the object they started from, and the wrap is unobservable. Cycling three lands on
// objects[65536 % 3] == objects[1] at exactly the same raw version.
for (Uint32 i = 1; i <= 65536u; ++i) slot.Bind(objects[i % 3]);
ASSERT_EQ(slot.GetVersion(), versionAtStart) << "the version did not come back round";
ASSERT_EQ(slot.GetBoundObject(), objects[1]) << "the binding did not land on a different buffer";
Walk();
EXPECT_NE(m_tracker.LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer), 0u)
<< "the slot version wrapped onto a DIFFERENT buffer and the bit stayed down - the "
"identity half of the shutter is what has to close that hole";
m_cache.Reset();
}
// THE PENDING BASE INSTANCE IS THIS CALL'S ARGUMENT, NOT A LATCH, and Update() is where
// the difference bites: it calls Reset() from inside itself whenever the current
// GLContext pointer moves, and the draw entry point wrote the value one statement EARLIER
// (D-H2.1 puts MGP_SET_BASE_INSTANCE immediately above MGP_FILL, and Update is inside
// MGP_FILL). So `eglMakeCurrent(ctxB); glDrawArraysInstancedBaseInstance(..., 7)` used to
// put a BaseInstance of 0 on the wire - one silently mis-shifted instanced draw per
// context switch, on the emulation path, invisible to a single-context retrace corpus and
// to every case that drives the emitter directly.
//
// A FRESH TRACKER IS EXACTLY THAT SWITCH: m_context starts null, so the first Walk() takes
// the same `m_context != &ctx` branch a make-current does.
TEST_F(TrackerWalk, ABaseInstanceSurvivesTheFirstWalkOnAFreshContext) {
m_tracker.SetPendingBaseInstance(7);
const Uint32 dirty = Walk();
EXPECT_EQ(m_tracker.PendingBaseInstance(), 7u)
<< "the walk that follows a make-current cleared the base instance the same call set";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewVertexBuffers), 0u)
<< "NEW_VERTEX_BUFFERS did not fire on the first walk of a fresh context";
// And the control, so the assertion above is about Reset() and not about a value that
// is never cleared at all: the verb that consumes it clears it, and the next walk on
// the SAME context then sees 0.
m_tracker.ClearPendingBaseInstance();
Walk();
EXPECT_EQ(m_tracker.PendingBaseInstance(), 0u);
m_cache.Reset();
}
// ===================================================================================
// set_vertex_attrib_defaults' payload (P2 brief D10)
// ===================================================================================
//
// A CurrentVertexAttributeValue is ONE value in three views and GLContext converts
// numerically between them, so four words on the wire are not the value unless the class
// travels with them. These pin exactly that, because nothing else can: the emission
// happens at step 3 and the residual fill re-pulls the field at step 4, so at a kDraw
// verb a wrong payload is overwritten before any comparator or backend read sees it -
// which is how a hard-coded ValueClass of 0 survived a green verify lane.
class TrackerAttribPayload : public ::testing::Test {
protected:
void SetUp() override {
m_previous = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
}
void TearDown() override { MG_State::pGLContext = Move(m_previous); }
static GLContext& Ctx() { return *MG_State::pGLContext; }
static MGPAttribValue PayloadFor(Uint location) {
MGPAttribValue value{};
MGPipeFillAttribValue(static_cast<Uint32>(location), Ctx().GetCurrentVertexAttribute(location),
Ctx().GetCurrentVertexAttributeClass(location), value);
return value;
}
static Uint32 Word(Float value) {
Uint32 bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
return bits;
}
UniquePtr<GLContext> m_previous;
};
TEST_F(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) {
Ctx().SetCurrentVertexAttributeFloat(3, Array<Float, 4>{1.5f, -2.5f, 3.0f, 4.0f});
const MGPAttribValue value = PayloadFor(3);
EXPECT_EQ(value.Location, 3u);
EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassFloat);
EXPECT_EQ(value.Data[0], Word(1.5f));
EXPECT_EQ(value.Data[1], Word(-2.5f));
// The defect this exists to stop: 1.5f's int VIEW is 1, and a carrier that sent the
// float bits while calling them class 0 for every attribute would be sending
// 0x3FC00000 where the frontend holds 1.
EXPECT_NE(value.Data[0], static_cast<Uint32>(Ctx().GetCurrentVertexAttribute(3).intValue[0]));
}
TEST_F(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) {
Ctx().SetCurrentVertexAttributeInt(5, Array<Int32, 4>{7, -9, 11, 13});
const MGPAttribValue value = PayloadFor(5);
EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassInt);
EXPECT_EQ(static_cast<Int32>(value.Data[0]), 7);
EXPECT_EQ(static_cast<Int32>(value.Data[1]), -9);
// and NOT the float view the frontend converted it into
EXPECT_NE(value.Data[0], Word(7.0f));
}
TEST_F(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) {
Ctx().SetCurrentVertexAttributeUint(6, Array<Uint32, 4>{4000000000u, 2u, 3u, 4u});
const MGPAttribValue value = PayloadFor(6);
EXPECT_EQ(value.ValueClass, MG_State::GLState::kVertexAttribValueClassUint);
EXPECT_EQ(value.Data[0], 4000000000u);
EXPECT_NE(value.Data[0], Word(4000000000.0f));
}
// The class is PER ATTRIBUTE and it is the last writer's, not the context's - a payload
// that took one attribute's class for all 32 would be the same defect as a hard-coded 0.
TEST_F(TrackerAttribPayload, TheSameNumbersWrittenThroughADifferentClassAreADifferentValue) {
Ctx().SetCurrentVertexAttributeFloat(1, Array<Float, 4>{1.0f, 2.0f, 3.0f, 4.0f});
Ctx().SetCurrentVertexAttributeInt(2, Array<Int32, 4>{1, 2, 3, 4});
EXPECT_EQ(PayloadFor(1).ValueClass, MG_State::GLState::kVertexAttribValueClassFloat);
EXPECT_EQ(PayloadFor(2).ValueClass, MG_State::GLState::kVertexAttribValueClassInt);
// Same numbers, different classes, so the same four words mean different things:
// 1.0f is 0x3F800000 and the integer 1 is 0x00000001.
EXPECT_NE(PayloadFor(1).Data[0], PayloadFor(2).Data[0]);
// An attribute nobody wrote answers Float, which is what the GL default (0,0,0,1) is.
EXPECT_EQ(PayloadFor(7).ValueClass, MG_State::GLState::kVertexAttribValueClassFloat);
// and a later write of the other class moves the class of THAT attribute only
Ctx().SetCurrentVertexAttributeUint(1, Array<Uint32, 4>{1u, 2u, 3u, 4u});
EXPECT_EQ(PayloadFor(1).ValueClass, MG_State::GLState::kVertexAttribValueClassUint);
EXPECT_EQ(PayloadFor(2).ValueClass, MG_State::GLState::kVertexAttribValueClassInt);
}
// ===================================================================================
// The SHIPPED emitter, driven through MGPipeValidateForVerb itself
// ===================================================================================
//
// TrackerWalk above reproduces step 3 against a local tracker and cache, which cannot
// fail on a defect in the validate point itself (a bit gated on the wrong subsystem, an
// emission dropped). These drive the real entry point and read the real singletons back.
// Safe because ctest runs one gtest case per process and the fixture resets all three
// pieces of server-side state on both sides of every case.
class TrackerShippedEmitter : public ::testing::Test {
protected:
void SetUp() override {
m_previous = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
m_savedPush = MG_Config::Features.PipePush;
MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2;
ResetTheServerSideSingletons();
}
void TearDown() override {
MGPipeLeaveVerb();
MG_Config::Features.PipePush = m_savedPush;
ResetTheServerSideSingletons();
MG_State::pGLContext = Move(m_previous);
}
static GLContext& Ctx() { return *MG_State::pGLContext; }
static void Draw() { MGPipeValidateForVerb(MGPipeVerb::DrawArrays); }
static const MGPipeCsoCache::Counters& Cso() { return MGPipeCsoCacheInstance().GetCounters(); }
Uint64 m_savedPush = 0;
UniquePtr<GLContext> m_previous;
};
TEST_F(TrackerShippedEmitter, ABlendToggleThroughTheValidatePointMintsTwoCsos) {
constexpr int kToggles = 16;
Draw(); // prime: a fresh context resets the cache inside the emitter and mints once
MGPipeCsoCacheInstance().ResetCounters();
for (int i = 0; i < kToggles; ++i) {
Ctx().SetCapability(CapabilityInput::Blend, true);
Draw();
Ctx().SetCapability(CapabilityInput::Blend, false);
Draw();
}
EXPECT_EQ(Cso().Mints, 1u) << "the blend-disabled state was already cached by the priming draw";
EXPECT_EQ(Cso().Binds, static_cast<Uint64>(2 * kToggles));
EXPECT_EQ(Cso().Hits, static_cast<Uint64>(2 * kToggles - 1));
EXPECT_EQ(MGPipeCsoCacheInstance().Size(), 2u);
}
TEST_F(TrackerShippedEmitter, TheSteadyStateThroughTheValidatePointEmitsNothing) {
Draw();
// The positive half, so "nothing was emitted" cannot pass because nothing is wired:
// the first draw on a fresh context mints and binds exactly one CSO.
ASSERT_EQ(Cso().Mints, 1u) << "the priming draw emitted no create_render_state at all";
ASSERT_EQ(Cso().Binds, 1u);
MGPipeCsoCacheInstance().ResetCounters();
for (int i = 0; i < 8; ++i) {
Draw();
EXPECT_EQ(MGPipeTrackerInstance().LastDirty(), 0u) << "walk " << i << " fired with nothing moved";
}
EXPECT_EQ(Cso().Mints, 0u);
EXPECT_EQ(Cso().Binds, 0u) << "a steady-state draw bound a render-state CSO";
}
// The window the repair covers: a glVertexAttrib* write followed by a verb whose class
// does NOT read m_currentVertexAttribute. The call still goes out (the dirty bit and the
// subsystem bit are all step 3 looks at), and today's applier writes four words into all
// three views because it ignores ValueClass, so nothing in step 4 puts the converted
// value back and the client repairs the mirror itself.
//
// THE ASSERTION IS THE INVARIANT, NOT THE DEFECT. "repairs == before + 1" would pin
// today's applier and go red the day package A teaches
// MGPipeApplySetVertexAttribDefaults to switch on MGPAttribValue::ValueClass - which is
// the hand-off this package declares as blocking, and which is supposed to need no edit
// here. What must hold either way is that the call went out naming exactly the attribute
// that moved, and that the mirror ends up right by at most one repair: zero repairs once
// the applier reproduces the value, one until then.
TEST_F(TrackerShippedEmitter, APushedAttributeDefaultTheApplierCannotReproduceIsRepaired) {
Draw();
const Uint64 before = MGPipeVertexAttribDefaultRepairCount();
// 1.5f is the point: its int view is 1 and its bit pattern is 0x3FC00000, so the two
// cannot be the same four words whichever view the carrier picks.
Ctx().SetCurrentVertexAttributeFloat(0, Array<Float, 4>{1.5f, 2.5f, 3.5f, 4.5f});
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
const MGPVertexAttribDefaults header = MGPipeVertexAttribDefaultsLastHeader();
ASSERT_EQ(header.Count, 1u) << "the moved attribute default did not go out at all";
EXPECT_EQ(header.Mask, 1u) << "the call named an attribute that did not move";
const Uint64 repairs = MGPipeVertexAttribDefaultRepairCount() - before;
EXPECT_LE(repairs, 1u) << "one call cannot need two repairs";
// and the repair is not free-running: a second identical walk moves nothing, so it
// neither re-emits nor re-repairs.
MGPipeValidateForVerb(MGPipeVerb::GenerateMipmap);
EXPECT_EQ(MGPipeVertexAttribDefaultRepairCount() - before, repairs);
}
// MAJOR 1 of round 2's review, pinned. glEnable(GL_CLIP_DISTANCE0) is one of the 35
// capabilities the residual block carries AND one of the eight whose SetCapability arm
// deliberately does not BumpVersions(), so it moves m_version alone. An arming condition
// that reads the PIPELINE version - which is what this emitter used - never re-arms for
// those eight, and nothing can see it downstream: a block that is not emitted cannot
// diverge, so the trip wire is simply disarmed.
TEST_F(TrackerShippedEmitter, AClipDistanceEnableReArmsTheResidualBlock) {
Draw();
ASSERT_TRUE(MGPipeApplier().HasResidual) << "the priming draw sent no residual block";
// Poison the server's copy so a re-emission is the only thing that can restore it.
MGPipeApplier().Residual = ResidualValueBlock{};
MGPipeApplier().HasResidual = false;
Ctx().SetCapability(CapabilityInput::ClipDistance0, true);
// The premise: this moved the render-state counter and NOT the pipeline one.
const Uint16 pipelineBefore = static_cast<Uint16>(Ctx().GetPipelineStateVersion());
Draw();
ASSERT_EQ(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u)
<< "the premise is gone: a clip-distance enable now moves the pipeline version";
EXPECT_EQ(static_cast<Uint16>(Ctx().GetPipelineStateVersion()), pipelineBefore);
ASSERT_TRUE(MGPipeApplier().HasResidual)
<< "a capability change that moves only m_version never re-armed the residual block";
const Uint64 bit = Uint64{1} << static_cast<SizeT>(CapabilityInput::ClipDistance0);
EXPECT_NE(MGPipeApplier().Residual.CapabilityBits & bit, 0ull)
<< "the re-emitted block does not carry the capability that moved";
}
// MAJOR 3 of round 2's review, pinned. A fresh context resets the tracker's staging
// mirror to the GL defaults, which are exactly what a fresh GLContext holds - so the
// per-attribute diff is empty on the one walk that must publish everything, while the
// applier's mirror still holds the PREVIOUS context's defaults.
TEST_F(TrackerShippedEmitter, AFreshContextRepublishesEveryVertexAttributeDefault) {
// The fixture's context is itself fresh, so the priming draw is the first half of the
// same statement: a fresh context publishes the COMPLETE set, not a difference.
Draw();
ASSERT_EQ(MGPipeVertexAttribDefaultsLastHeader().Count, 32u)
<< "the first walk on a fresh context published an increment, not a complete state";
Ctx().SetCurrentVertexAttributeFloat(3, Array<Float, 4>{9.f, 8.f, 7.f, 6.f});
Draw();
ASSERT_EQ(MGPipeVertexAttribDefaultsLastHeader().Count, 1u)
<< "a steady context published more than the one attribute that moved";
// A different context, whose 32 defaults are the value-initialised {0,0,0,1} the
// tracker's own reset produces - so a diff against the staging mirror finds nothing.
MG_State::pGLContext = MakeUnique<GLContext>();
Draw();
const MGPVertexAttribDefaults header = MGPipeVertexAttribDefaultsLastHeader();
EXPECT_EQ(header.Count, 32u)
<< "a fresh context published " << header.Count
<< " attribute defaults; the server's mirror still holds the previous context's";
EXPECT_EQ(header.Mask, 0xFFFFFFFFu);
}
// Minor 4 of round 2's review. The fresh-context reset of the applier and the CSO cache
// used to sit inside EmitRenderState, i.e. behind bit 0 of MOBILEGL_PIPE_PUSH, so the
// per-subsystem A/B D14 invites gave a fresh context a never-reset applier holding the
// previous context's CSO records while every suppressor slot WAS invalidated.
TEST_F(TrackerShippedEmitter, AFreshContextResetsTheApplierWithTheRenderStateSubsystemOff) {
Draw();
ASSERT_FALSE(MGPipeApplier().RenderStateCsos.empty())
<< "the priming draw created no CSO record to leak into the next context";
MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2 & ~kMGPipeSubsystemRenderState;
MG_State::pGLContext = MakeUnique<GLContext>();
Draw();
EXPECT_TRUE(MGPipeApplier().RenderStateCsos.empty())
<< "a fresh context kept the previous context's CSO records because the reset was "
"behind the render-state subsystem bit";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundRenderStateCso));
}
TEST_F(TrackerShippedEmitter, AViewportThroughTheValidatePointMintsNoCso) {
Draw();
ASSERT_EQ(Cso().Mints, 1u) << "the priming draw emitted no create_render_state at all";
MGPipeCsoCacheInstance().ResetCounters();
for (Int i = 1; i <= 8; ++i) {
Ctx().SetViewport(IntVec4(0, 0, 64 + i, 48 + i));
Draw();
EXPECT_NE(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewRenderState), 0u);
EXPECT_EQ(MGPipeTrackerInstance().LastDirty() & MGPipeDirtyBit(MGPipeDirty::NewPipelineState), 0u);
}
EXPECT_EQ(Cso().Mints, 0u);
EXPECT_EQ(Cso().Binds, 0u) << "glViewport reached the CSO cache";
}
// B-C1's scenario END TO END, through the shipped entry point and read back off the real
// applier: the setter runs, THEN a make-current happens inside the same fill (the fresh
// GLContext below is the switch), and what set_vertex_buffers carries has to be the 7 the
// draw entry point passed - not the 0 the tracker's context Reset used to leave behind.
//
// It also pins the two halves D-H2.3 makes one property: the applier's raw
// VertexFetchBaseInstance, and the ContentHash the suppressor keys on - which has to mix
// BaseInstance in, or the SECOND draw at a different base instance over the same buffer
// set would be suppressed as unchanged and the server would keep the first one's shift.
TEST_F(TrackerShippedEmitter, ABaseInstancedDrawAfterAMakeCurrentPublishesItsOwnBaseInstance) {
MG_Config::Features.PipePush = kMGPipeSubsystemsMigratedAtP2 | kMGPipeSubsystemResources |
kMGPipeSubsystemVertexInput;
Draw(); // prime this context, so the switch below is a real make-current
ASSERT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u);
// The make-current, then the entry point's one line, then the fill - in that order,
// which is the order GL_Drawing.cpp has.
MG_State::pGLContext = MakeUnique<GLContext>();
const SharedPtr<MG_State::GLState::BufferObject> vertices = Ctx().CreateBufferObject(1);
vertices->Respecify(4096, nullptr);
Ctx().GetBoundVertexArray()->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false, false, -1);
Ctx().GetBoundVertexArray()->BindAttributeBuffer(0, vertices);
Ctx().GetBoundVertexArray()->EnableAttribute(0);
MGPipeSetPendingBaseInstance(7);
ASSERT_EQ(MGPipePendingBaseInstance(), 7u) << "the setter did not take";
Draw();
EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 7u)
<< "the make-current between the setter and the fill ate the base instance";
EXPECT_EQ(MGPipeApplier().VertexBufferCount, 1u);
const Uint64 hashAtSeven = MGPipeVertexInputEmitterInstance().LastVertexBuffers().ContentHash;
EXPECT_EQ(hashAtSeven,
MGPipeVertexBufferSetContentHash(MGPipeVertexInputEmitterInstance().LastEntries().data(), 0,
1, 7))
<< "the emitted set's ContentHash does not include the base instance it went out with";
// CONSUMED by the verb that carried it: the next plain draw sees 0 again, and the
// clear that makes that true is the validate point's, not MGPipeLeaveVerb's (no GL
// entry point calls that one).
EXPECT_EQ(MGPipePendingBaseInstance(), 0u);
const Uint64 setsAtSeven = MGPipeVertexInputEmitterInstance().VertexBufferSetCount();
Draw();
EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u)
<< "a plain draw after a base-instanced one kept the previous fetch shift";
EXPECT_GT(MGPipeVertexInputEmitterInstance().VertexBufferSetCount(), setsAtSeven)
<< "the buffer set was suppressed on a base-instance-only change - the hash or the "
"bit-9 shutter is missing it";
EXPECT_NE(MGPipeVertexInputEmitterInstance().LastVertexBuffers().ContentHash, hashAtSeven);
// THE OTHER EXIT. MGPipeValidateForVerb returns early when there is no live context,
// which skips step 3 and therefore skips step 3's clear - and since Reset() no longer
// clears it either, that exit is the only path left on which a base instance could
// stand into the next verb. A draw with no context is a no-op; its argument must not
// outlive it.
MGPipeSetPendingBaseInstance(11);
UniquePtr<GLContext> parked = Move(MG_State::pGLContext);
Draw();
EXPECT_EQ(MGPipePendingBaseInstance(), 0u)
<< "the no-live-context exit left the draw's base instance standing for the next verb";
MG_State::pGLContext = Move(parked);
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
@@ -0,0 +1,635 @@
// MobileGL - MobileGL/MG_Test/Pipe/VertexInputEmitTest.cpp
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P3a's vertex-input family: create/bind/delete_vertex_elements, set_vertex_buffers and
// set_index_buffer, on both sides of the call.
//
// THIS SUITE IS A NAMED GATE. The phase's G6 is "for every VAO configuration the emitted
// MGPVertexElements blob + MGPVertexBuffers set + MGPIndexBuffer reproduce exactly the values
// the backend's VAO twin reads from the frontend today, field by field, for all 32 attribute
// slots", and it is spelled `ctest -R 'VertexInputEmit\.'`; G7 is its negative control, a
// script that stops the wire conversion copying ONE field and expects this suite to go red
// NAMING that field. So a case here must fail by field name, never by a bare count, or the
// control cannot answer.
//
// THE SUITE IS `VertexInputEmit`, not `VertexInputEmitTest`: the file is XTest.cpp and the
// suite is X, this directory's convention (RenderStateSpansTest.cpp -> RenderStateSpans), and
// it is what both gates grep for.
//
// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT - the
// client package writes the conversion cases, the base-instance suppression pair and the
// create/bind ping-pong pair into this file without touching MG_Test/Pipe/CMakeLists.txt.
//
// IT HAS ITS OWN main() for the same reason ResourceEmitTest does: the applier refuses a
// vertex-elements record whose declared counts do not describe its own blob, and that verdict
// is a log line in a shipped push build and std::abort() in a poison or verify one.
//
// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N`
// stays name-for-name identical between the pull and the push trees.
#include <gtest/gtest.h>
#include <filesystem>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#else
#include <unistd.h>
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <algorithm>
#include <Config.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/VertexInputEmit.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
// A MAKE-CURRENT CLEARS THE WORKING STATE AND ADVANCES THE SERIALS, and for this family
// the difference between those two verbs is the whole of the rule. The backend's VAO twin
// decides "have I already synced this?" by comparing its own memo against the serials, and
// the twin does NOT die with a make-current - it is destroyed with the context, and D-G4
// deletes the wrapping-version-plus-identity patch that used to cover the gap. So there
// are three things a reset could do to a serial whose state it has just cleared and only
// one of them is right: carrying the count over lets a twin read clean over a cleared
// window immediately; RESTARTING AT 0 walks the counter back up through every value it has
// already stamped into a surviving twin, which is worse because it is silent and reliable;
// advancing announces the clearing and can never hand out a stamped value again.
//
// So: the bound handle is null rather than "whatever was bound", the window is empty
// rather than 32 stale entries, the fetch shift is 0 rather than the last draw's - and the
// two serials have MOVED FORWARD. The applier's OBJECT records are a different scope
// entirely and are deliberately not touched here; ResourceEmit's
// TheObjectRecordsSurviveAMakeCurrentAndOnlyTheWorkingStateIsReset is where that is driven
// with live records in the table.
TEST(VertexInputEmit, AResetApplierCarriesNoVertexInputStateOver) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
MGPipeApplierState& applier = MGPipeApplier();
applier.BoundVertexElements = MGPipeHandle{7, 3};
applier.VertexBufferStart = 1;
applier.VertexBufferCount = 5;
applier.VertexFetchBaseInstance = 9;
applier.VertexBuffersSerial = 42;
applier.IndexBufferSerial = 43;
applier.MapPersistentRoundtrips = 44;
MGPipeApplierReset();
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundVertexElements));
EXPECT_EQ(MGPipeApplier().VertexBufferStart, 0u);
EXPECT_EQ(MGPipeApplier().VertexBufferCount, 0u);
EXPECT_EQ(MGPipeApplier().VertexFetchBaseInstance, 0u);
EXPECT_EQ(MGPipeApplier().MapPersistentRoundtrips, 0u);
// MOVED FORWARD, not zeroed. 43 and 44 are the successors of the 42 and 43 above, and
// the property that matters is the strict inequality: no value this counter has
// already handed to a twin may ever come back.
EXPECT_EQ(MGPipeApplier().VertexBuffersSerial, 43u);
EXPECT_EQ(MGPipeApplier().IndexBufferSerial, 44u);
EXPECT_GT(MGPipeApplier().VertexBuffersSerial, 42u);
EXPECT_GT(MGPipeApplier().IndexBufferSerial, 43u);
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 requires the pull and push ctest name sets to be identical, name for name, so a
// push-only case is present and SKIPS rather than being absent.
#define MGL_VERTEX_INPUT_EMIT_TEST_LIST(X) \
X(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) \
X(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) \
X(VertexInputEmit, IsLongAndFloat64TravelSeparately) \
X(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) \
X(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) \
X(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) \
X(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) \
X(VertexInputEmit, DestroyedVertexArraysReturnTheirCsoSlotsAndRecords) \
X(VertexInputEmit, ADoubleReleaseOfAVertexElementsSlotIsHarmless)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_VERTEX_INPUT_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::BufferObject;
using MG_State::GLState::VertexArrayObject;
// The emitters are driven DIRECTLY rather than through MGPipeValidateForVerb, and that
// is the point: G6 is a statement about the conversion, and a case that went through the
// validate point would also be testing the tracker's shutters, which have their own
// suite. What is asserted is what the emitter handed the applier - on this tree the
// applier's entry points are stubs, so the emitter's own staging buffers ARE the
// emitted record, at no copy.
//
// AN RAII SCOPE RATHER THAN A gtest FIXTURE: both gates grep `ctest -R
// 'VertexInputEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and gtest
// refuses to mix TEST and TEST_F under one suite name - so a fixture would rename every
// case out of the gate's reach.
struct EmitterScope {
EmitterScope() {
m_previousContext = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
MGPipeVertexInputEmitterInstance().Reset();
MGPipeVertexInputEmitterInstance().ResetCounters();
MGPipeSetHashSuppressorInstance().InvalidateAll();
}
~EmitterScope() {
MG_State::pGLContext.reset();
MG_State::pGLContext = Move(m_previousContext);
MGPipeVertexInputEmitterInstance().Reset();
MGPipeVertexInputEmitterInstance().ResetCounters();
MGPipeSetHashSuppressorInstance().InvalidateAll();
}
EmitterScope(const EmitterScope&) = delete;
EmitterScope& operator=(const EmitterScope&) = delete;
UniquePtr<GLContext> m_previousContext;
};
GLContext& Ctx() { return *MG_State::pGLContext; }
MGPipeVertexInputEmitter& Emitter() { return MGPipeVertexInputEmitterInstance(); }
const SharedPtr<VertexArrayObject>& MakeVao(Uint name) {
Ctx().CreateVertexArrayObject(name);
Ctx().BindVertexArray(name);
return Ctx().GetBoundVertexArray();
}
// ============================ G6 ============================
//
// "For every VAO configuration the emitted MGPVertexElements blob reproduces EXACTLY the
// values the backend's VAO twin reads from the frontend today, field by field, for all 32
// attribute slots."
//
// The oracle is the frontend attribute itself, read back through the same getter the twin
// uses, so this cannot drift into asserting what the emitter happens to do. Every field is
// its own EXPECT naming that field, which is what G7's scripted control needs: it stops
// the conversion copying ONE member and expects this case to go red NAMING it.
//
// All three configuration families are driven, because they resolve differently and a
// conversion that works for one is not evidence about the others: the legacy pointer
// entry points (which resolve a 0 stride to the element size before it ever reaches the
// wire), the ARB_vertex_attrib_binding entry points (where a 0 stride means the opposite
// and must survive), and the enable/disable switch.
TEST(VertexInputEmit, EveryAttributeFieldSurvivesTheWireConversion) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
buffer->Respecify(4096, nullptr);
constexpr int kAttribs = VertexArrayObject::MAX_VERTEX_ATTRIBS;
const DataType kTypes[] = {DataType::Float32, DataType::Int16, DataType::Uint8,
DataType::Int32, DataType::Float64, DataType::Uint2101010Rev};
for (int i = 0; i < kAttribs; ++i) {
const auto index = static_cast<Uint>(i);
const DataType type = kTypes[i % 6];
const int size = 1 + (i % 4);
const Bool normalized = (i % 3) == 0;
const Bool isInteger = (i % 5) == 0;
if (i < 12) {
// The legacy pointer family: a raw stride, an effective stride and a pointer
// offset, all three distinct so a conversion that took the wrong one fails.
vao->SetAttributeFormat(index, size, type, normalized, 16 + i, static_cast<SizeT>(64 + i * 4),
isInteger, false, 32 + i);
vao->MirrorPointerIntoBinding(index, buffer, static_cast<SizeT>(64 + i * 4), 32 + i);
vao->BindAttributeBuffer(index, buffer);
vao->SetAttributeDivisor(index, static_cast<Uint>(i % 3));
} else if (i < 24) {
// The binding-model family, with the attribute deliberately fed by a DIFFERENT
// binding index than its own - which is the one thing MGPVertexAttribWire::
// BindingIndex exists to carry and the one an identity mapping would hide.
const Uint binding = static_cast<Uint>((i + 5) % kAttribs);
vao->SetAttributeFormatSeparate(index, size, type, normalized, isInteger,
static_cast<Uint>(8 * (i % 4)), false, type == DataType::Float64);
vao->SetAttributeBinding(index, binding);
vao->SetBindingBuffer(binding, buffer, static_cast<SizeT>(128 + i), 48 + i);
vao->SetBindingDivisor(binding, static_cast<Uint>(i % 2));
} else {
// GL_BGRA keeps size 4 and is its own flag; the disabled tail proves Enabled
// travels rather than being implied by "has a format".
vao->SetAttributeFormat(index, 4, DataType::Uint8, true, 0, static_cast<SizeT>(i), false, true, -1);
}
if ((i % 2) == 0) {
vao->EnableAttribute(index);
} else {
vao->DisableAttribute(index);
}
}
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "a fresh VAO must publish a create";
ASSERT_EQ(Emitter().CreateCount(), 1u);
EXPECT_EQ(Emitter().LastElements().AttributeCount, static_cast<Uint32>(kAttribs));
EXPECT_EQ(Emitter().LastElements().BindingPointCount,
static_cast<Uint32>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS));
EXPECT_EQ(Emitter().LastElements().Blob.Size,
static_cast<Uint64>(kAttribs) * sizeof(MGPVertexAttribWire) +
static_cast<Uint64>(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS) *
sizeof(MGPVertexBindingPointWire))
<< "the declared counts must describe the blob's declared size, or the applier refuses it";
for (int i = 0; i < kAttribs; ++i) {
const auto index = static_cast<Uint>(i);
const auto& attrib = vao->GetAttribute(index);
const MGPVertexAttribWire& wire = Emitter().LastAttributes()[static_cast<SizeT>(i)];
SCOPED_TRACE(::testing::Message() << "attribute " << i);
EXPECT_EQ(wire.Offset, static_cast<Uint64>(attrib.Offset));
EXPECT_EQ(wire.Stride, static_cast<Int32>(attrib.Stride));
EXPECT_EQ(wire.Type, static_cast<Uint32>(attrib.Type));
EXPECT_EQ(wire.Size, static_cast<Uint8>(attrib.Size));
EXPECT_EQ(wire.Enabled, attrib.Enabled ? 1 : 0);
EXPECT_EQ(wire.Normalized, attrib.Normalized ? 1 : 0);
EXPECT_EQ(wire.IsInteger, attrib.IsInteger ? 1 : 0);
EXPECT_EQ(wire.IsLong, attrib.IsLong ? 1 : 0);
EXPECT_EQ(wire.IsBgra, attrib.IsBgra ? 1 : 0);
EXPECT_EQ(wire.BindingIndex, static_cast<Uint8>(vao->GetAttributeBindingIndex(index)));
EXPECT_EQ(wire.Pad0, 0u) << "padding must stay padding";
}
for (int b = 0; b < VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS; ++b) {
const auto& point = vao->GetBindingPoint(static_cast<Uint>(b));
const MGPVertexBindingPointWire& wire = Emitter().LastBindingPoints()[static_cast<SizeT>(b)];
SCOPED_TRACE(::testing::Message() << "binding point " << b);
EXPECT_EQ(wire.Offset, static_cast<Uint64>(point.Offset));
EXPECT_EQ(wire.Stride, static_cast<Int32>(point.Stride));
EXPECT_EQ(wire.Divisor, static_cast<Uint32>(point.Divisor));
}
// The divisor is NOT in the attribute view - it is resolved per binding point and
// travels in MGPVertexBuffer::Divisor, which is where the backend reads it. Asserted
// here rather than left to a reader of the struct, because carrying it twice is
// exactly how a malformed record comes to disagree with itself.
Emitter().EmitVertexBuffers(Ctx(), 0);
for (Uint32 i = 0; i < Emitter().LastVertexBuffers().Count; ++i) {
const auto& attrib = vao->GetAttribute(i);
const MGPVertexBuffer& entry = Emitter().LastEntries()[i];
SCOPED_TRACE(::testing::Message() << "vertex buffer entry " << i);
EXPECT_EQ(entry.Divisor, static_cast<Uint32>(attrib.Divisor));
EXPECT_EQ(entry.Stride, static_cast<Uint32>(attrib.Stride));
EXPECT_EQ(entry.BindingIndex, i);
EXPECT_EQ(entry.Offset, 0u) << "the attribute's own byte offset lives in the wire attribute";
}
}
// KHR-GL43.vertex_attrib_binding.basic-input-case7/8: a pointer call's stride 0 means
// "tightly packed" and the frontend already resolved it to the element size, so a zero
// that reaches the wire can only have come from the binding model - where it means every
// vertex reads the SAME element and the fetch address never advances. Collapsing it back
// into the element size is what made those two cases read past the buffer.
TEST(VertexInputEmit, ABindingModelStrideOfZeroSurvivesAsZero) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
buffer->Respecify(256, nullptr);
vao->SetAttributeFormatSeparate(0, 4, DataType::Float32, false, false, 0);
vao->SetAttributeBinding(0, 0);
vao->SetBindingBuffer(0, buffer, 0, 0); // the binding model's zero
vao->EnableAttribute(0);
// The control, on the SAME emission: a pointer-style zero was already resolved to the
// tightly packed element size by the GL entry point (which is what the effective
// stride argument carries), so it must NOT reach the wire as a zero. The raw argument
// stays 0 and is reported verbatim by glGetVertexAttribiv - which is exactly why the
// two are stored apart and only the resolved one travels.
vao->SetAttributeFormat(1, 4, DataType::Float32, false, 0, 0, false, false, 16);
vao->MirrorPointerIntoBinding(1, buffer, 0, 16);
vao->BindAttributeBuffer(1, buffer);
vao->EnableAttribute(1);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
ASSERT_EQ(vao->GetAttribute(0).Stride, 0) << "the frontend itself no longer resolves this to zero";
EXPECT_EQ(Emitter().LastAttributes()[0].Stride, 0)
<< "a binding-model stride of 0 was collapsed into the element size";
EXPECT_EQ(Emitter().LastBindingPoints()[0].Stride, 0);
EXPECT_NE(Emitter().LastAttributes()[1].Stride, 0) << "a resolved pointer stride reached the wire as 0";
EXPECT_EQ(Emitter().LastAttributes()[1].Stride, static_cast<Int32>(vao->GetAttribute(1).Stride));
EXPECT_EQ(vao->GetAttribute(1).LegacyStride, 0) << "the raw query answer is not the resolved one";
Emitter().EmitVertexBuffers(Ctx(), 0);
EXPECT_EQ(Emitter().LastEntries()[0].Stride, 0u) << "and the set has to agree with the format";
}
// VertexAttribFormat(GL_DOUBLE) reads doubles from memory and asks for them CONVERTED to
// float; VertexAttribLFormat keeps all 64 bits. The backend's fp64 narrowing and its
// Adreno disabled-attribute workaround both key on telling the two apart, so IsLong may
// never be inferred from Type == Float64.
TEST(VertexInputEmit, IsLongAndFloat64TravelSeparately) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
// Attribute 0: GL_DOUBLE, converted to float. Attribute 1: the same type, kept long.
vao->SetAttributeFormatSeparate(0, 4, DataType::Float64, false, false, 0, false, false);
vao->SetAttributeFormatSeparate(1, 4, DataType::Float64, false, false, 0, false, true);
// Attribute 2: NOT a double, and not long either - so "IsLong implies Float64" is
// asserted in both directions.
vao->SetAttributeFormatSeparate(2, 4, DataType::Float32, false, false, 0, false, false);
vao->EnableAttribute(0);
vao->EnableAttribute(1);
vao->EnableAttribute(2);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
const auto& wires = Emitter().LastAttributes();
EXPECT_EQ(wires[0].Type, static_cast<Uint32>(DataType::Float64));
EXPECT_EQ(wires[0].IsLong, 0) << "a converted double must not travel as long";
EXPECT_EQ(wires[1].Type, static_cast<Uint32>(DataType::Float64));
EXPECT_EQ(wires[1].IsLong, 1) << "an L-format double lost its long flag";
EXPECT_EQ(wires[2].Type, static_cast<Uint32>(DataType::Float32));
EXPECT_EQ(wires[2].IsLong, 0);
// And the frontend agrees, so this is not the emitter asserting its own answer.
EXPECT_EQ(vao->GetAttribute(0).IsLong, false);
EXPECT_EQ(vao->GetAttribute(1).IsLong, true);
}
// D-H2.3, THE SUPPRESSOR TRAP. set_vertex_buffers is suppressed on an unchanged content
// hash. The base instance is DRAW state and moves without the buffer set moving, so a
// hash that did not include it would suppress the one record whose changed field is the
// fetch shift, and the server would keep the previous one - silently wrong geometry on
// instanced draws, and no desktop SSIM case need exercise it.
TEST(VertexInputEmit, ABaseInstanceChangeAloneStillEmitsTheVertexBufferSet) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
buffer->Respecify(256, nullptr);
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
vao->BindAttributeBuffer(0, buffer);
vao->SetAttributeDivisor(0, 1);
vao->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u) << "the first set always goes out";
ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u);
const Uint64 firstHash = Emitter().LastVertexBuffers().ContentHash;
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u);
// NOTHING about the buffer set changed; only the draw's base instance.
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 7), 0u)
<< "a base-instance-only change was suppressed - it is not in the content hash";
EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u);
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 7u)
<< "the RAW value the draw carried, never a pre-shifted offset";
EXPECT_NE(Emitter().LastVertexBuffers().ContentHash, firstHash);
// And back to zero is a change too - which is what makes a plain draw after a
// base-instanced one undo the shift.
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 0), 0u);
EXPECT_EQ(Emitter().LastVertexBuffers().BaseInstance, 0u);
EXPECT_EQ(Emitter().LastVertexBuffers().ContentHash, firstHash)
<< "the hash is a function of the set and the base instance, so it has to come back";
}
// The counterpart, and the reason the suppressor exists at all: an unchanged set with an
// unchanged base instance is not a record worth sending, and the slot must say so.
TEST(VertexInputEmit, AnUnchangedSetWithAnUnchangedBaseInstanceEmitsNothing) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> vao = MakeVao(1);
const SharedPtr<BufferObject> buffer = Ctx().CreateBufferObject(1);
buffer->Respecify(256, nullptr);
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
vao->BindAttributeBuffer(0, buffer);
vao->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u);
ASSERT_EQ(Emitter().VertexBufferSetCount(), 1u);
const Uint64 latched =
MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers);
EXPECT_NE(latched, 0u) << "0 is reserved for 'never emitted'";
EXPECT_EQ(Emitter().EmitVertexBuffers(Ctx(), 3), 0u) << "an unchanged set went out again";
EXPECT_EQ(Emitter().VertexBufferSetCount(), 1u);
EXPECT_EQ(MGPipeSetHashSuppressorInstance().LastEmitted(MGPipeSuppressorSlot::SetVertexBuffers),
latched);
// A real change to the SET still goes out with the same base instance, so the
// suppression above is not simply "this slot is stuck".
vao->SetAttributeDivisor(0, 4);
EXPECT_GT(Emitter().EmitVertexBuffers(Ctx(), 3), 0u);
EXPECT_EQ(Emitter().VertexBufferSetCount(), 2u);
}
// D-G3's per-handle latch. create_vertex_elements is re-issued on the SAME handle when a
// configuration moves, and the latch is stored per handle rather than globally so that
// rebinding cannot look like a configuration change.
TEST(VertexInputEmit, RebindingTheSameVaoEmitsABindAndNoCreate) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> a = MakeVao(1);
a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
a->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
EXPECT_EQ(Emitter().CreateCount(), 1u);
EXPECT_EQ(Emitter().BindCount(), 1u);
// Same VAO, same configuration: nothing at all.
EXPECT_EQ(Emitter().EmitVertexElements(Ctx()), 0u);
EXPECT_EQ(Emitter().CreateCount(), 1u);
EXPECT_EQ(Emitter().BindCount(), 1u);
// Away and back. The bind is re-emitted because the server's bound handle moved; the
// create is not, because this handle already published this configuration.
MakeVao(2);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
EXPECT_EQ(Emitter().CreateCount(), 2u);
EXPECT_EQ(Emitter().BindCount(), 2u);
Ctx().BindVertexArray(1);
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
EXPECT_EQ(Emitter().CreateCount(), 2u) << "a rebind re-created a configuration that had not moved";
EXPECT_EQ(Emitter().BindCount(), 3u);
// A configuration change on the BOUND VAO re-creates on the same handle and does NOT
// rebind: the server's bound handle did not move.
const MGPipeHandle bound = Emitter().BoundHandle();
a->SetAttributeFormat(1, 2, DataType::Int16, true, 8, 4, true);
a->EnableAttribute(1);
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
EXPECT_EQ(Emitter().CreateCount(), 3u);
EXPECT_EQ(Emitter().BindCount(), 3u) << "a re-create must not rebind";
EXPECT_EQ(Emitter().LastElements().Cso, bound) << "and it must land on the SAME handle";
}
// The latch is per handle, so alternating between two VAOs re-binds and never re-creates.
// A global latch would re-create both on every swap - strictly more work than the tree
// does today, which is the trade D-G1's identity-addressed CSO exists to avoid.
TEST(VertexInputEmit, PingPongingBetweenTwoVaosNeverRecreatesEither) {
EmitterScope scope;
const SharedPtr<VertexArrayObject> a = MakeVao(1);
a->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
a->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
const SharedPtr<VertexArrayObject> b = MakeVao(2);
b->SetAttributeFormat(0, 2, DataType::Int16, true, 8, 4, true);
b->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
ASSERT_EQ(Emitter().CreateCount(), 2u);
const MGPipeHandle handleB = Emitter().BoundHandle();
for (int i = 0; i < 8; ++i) {
Ctx().BindVertexArray(1);
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
Ctx().BindVertexArray(2);
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
}
EXPECT_EQ(Emitter().CreateCount(), 2u) << "ping-ponging re-created a VAO's configuration";
EXPECT_EQ(Emitter().BindCount(), 2u + 16u);
EXPECT_EQ(Emitter().BoundHandle(), handleB) << "the two VAOs swapped handles";
// Unbinding entirely publishes the null handle, once.
Ctx().BindVertexArray(0);
EXPECT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "the default VAO is a VAO and has a handle";
EXPECT_EQ(Emitter().CreateCount(), 3u);
}
// ============================ C-1: the death path ============================
//
// THE LEAK THIS RULES OUT, and why it is a client case rather than a backend one. Every
// validate point with a VAO bound calls MGPipeSlots().Acquire(VertexElementsCso, ...) and
// MGPipeApplyCreateVertexElements, and the applier's record is an Array<...,32> pair -
// about 1.3 KB per slot. Until C-1 the ONLY thing that ever returned one was DirectGLES'
// StateObjectDeathOps table, so under any backend that installs none - which is what
// DirectVulkan/Magma deliberately does, MagmaPipeArms.h says why - a VAO's slot and its
// record were held for the life of the process, on the shipped 0x1ff mask. Sodium and
// Create churn a VAO per chunk section; 10 k of them is 13 MB of records plus 10 k
// SlotState entries plus 10 k map nodes, monotonic, on a platform with an LMK, and past
// kMGPipeMaxVertexElementsSlots every create_vertex_elements becomes a permanent
// Fatal{ProtocolCorruption}.
//
// This binary installs NO StateObjectDeathOps at all, which is exactly the shape of the
// backend the leak was invisible under. Before C-1 the two EXPECTs on LiveCount below read
// `live + kChurn` and `HighWater` grew by kChurn; after it, both come back.
TEST(VertexInputEmit, DestroyedVertexArraysReturnTheirCsoSlotsAndRecords) {
EmitterScope scope;
ASSERT_EQ(MG_State::GLState::GetStateObjectDeathOps(), nullptr)
<< "this case is the NO-death-ops backend; with a consumer installed it would be "
"measuring Espryt's free instead of the client's";
auto& slots = MGPipeSlots();
const Uint32 liveBefore = slots.LiveCount(MGPipeKind::VertexElementsCso);
const Uint32 highWaterBefore = slots.HighWater(MGPipeKind::VertexElementsCso);
// Every round is one VAO, configured, drawn with (which is what mints the slot AND
// publishes the record), then deleted. The names are reused, exactly as a chunk
// renderer's are - MG_State hands out a fresh lifetime id per object anyway, so a
// recycled NAME must not be what returns the slot.
constexpr int kChurn = 64;
Uint32 peakLive = 0;
for (int round = 0; round < kChurn; ++round) {
// Through the name allocator, so MarkVertexArrayForDeletion recognises the name and
// actually drops the slot's reference - and so the recycled NAME is part of the
// shape, exactly as a chunk renderer's is. The lifetime id is fresh every round
// whatever the name does, which is what the free has to key on.
Vector<Uint> names;
Ctx().GenVertexArrayNames(1, names);
ASSERT_EQ(names.size(), 1u) << "round " << round;
const Uint name = names[0];
Ctx().CreateVertexArrayObject(name);
Ctx().BindVertexArray(name);
const SharedPtr<VertexArrayObject> vao = Ctx().GetBoundVertexArray();
ASSERT_TRUE(vao) << "round " << round;
vao->SetAttributeFormat(0, 4, DataType::Float32, false, 16, 0, false);
vao->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u) << "round " << round;
peakLive = std::max(peakLive, slots.LiveCount(MGPipeKind::VertexElementsCso));
// Unbind first: a still-bound VAO goes on living, which is the whole reason the
// death path hangs off the destructor and not off glDeleteVertexArrays.
Ctx().BindVertexArray(0);
Ctx().MarkVertexArrayForDeletion(name);
}
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore)
<< kChurn << " vertex arrays were created and destroyed and the client kept their "
"CSO slots; under a backend that installs no death notice consumer "
"that is one SlotState, one map node and a ~1.3 KB applier record per "
"VAO, for the life of the process";
// The default VAO plus one recycled slot, i.e. the churn recycles instead of growing.
EXPECT_LE(slots.HighWater(MGPipeKind::VertexElementsCso) - highWaterBefore, 3u)
<< "the CSO slot space grew with the churn instead of being recycled";
EXPECT_LE(peakLive, liveBefore + 2u) << "more than one churned VAO was live at once";
EXPECT_EQ(MGPipeApplier().RefusedVertexInputCalls, 0u)
<< "a death path emitted delete_vertex_elements for a record the applier never had";
}
// Espryt's death notice frees the same slot the client's death path frees, and after C-1
// both run. The allocator's Free is what makes that safe - it refuses a slot that is not
// live at that generation, and the Gen bump rides the NEXT handout rather than the free -
// so a second release cannot skip a generation, cannot double-push the free list, and
// cannot take a slot away from the successor that has meanwhile been given it.
TEST(VertexInputEmit, ADoubleReleaseOfAVertexElementsSlotIsHarmless) {
EmitterScope scope;
auto& slots = MGPipeSlots();
const Uint32 liveBefore = slots.LiveCount(MGPipeKind::VertexElementsCso);
const SharedPtr<VertexArrayObject> vao = MakeVao(11);
vao->EnableAttribute(0);
ASSERT_GT(Emitter().EmitVertexElements(Ctx()), 0u);
const MGPipeHandle handle = Emitter().BoundHandle();
ASSERT_FALSE(MGPipeHandleIsNull(handle));
const Uint64 lifetimeId = vao->GetLifetimeId();
// The notice's half, by hand and FIRST - the order Espryt's consumer runs in.
slots.Free(MGPipeKind::VertexElementsCso, handle);
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore);
// ...and then the client's, which must find nothing and say so rather than corrupt the
// free list or emit a delete for a record it has already forgotten.
EXPECT_FALSE(MGPipeEmitVertexElementsDestroyAndFree(lifetimeId))
<< "a second release resolved a handle the first one retired";
EXPECT_EQ(slots.LiveCount(MGPipeKind::VertexElementsCso), liveBefore);
// The successor takes the recycled slot with a MOVED generation, which is the property
// a double free would have broken.
const MGPipeHandle successor = slots.Acquire(MGPipeKind::VertexElementsCso, lifetimeId + 1);
EXPECT_EQ(successor.Slot, handle.Slot);
EXPECT_NE(successor.Gen, handle.Gen);
slots.Free(MGPipeKind::VertexElementsCso, successor);
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
int main(int argc, char** argv) {
// Before anything logs: the logger reads this variable once, on its first write, and
// caches the handle. The name carries this process's pid, and the file is removed on the
// way out.
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-vertexinputemit-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -45,7 +45,7 @@ namespace MobileGL::MG_Test {
explicit ScopedPipeVerb([[maybe_unused]] MG_Pipe::MGPipeVerb verb)
#if MOBILEGL_PIPE_PUSH
: m_verb(verb) {
MG_Pipe::MGPipeFillForVerb(m_verb);
MG_Pipe::MGPipeValidateForVerb(m_verb);
}
#else
{
@@ -60,7 +60,7 @@ namespace MobileGL::MG_Test {
// entry point's MGP_FILL would.
void Renew() {
#if MOBILEGL_PIPE_PUSH
MG_Pipe::MGPipeFillForVerb(m_verb);
MG_Pipe::MGPipeValidateForVerb(m_verb);
#endif
}
+1
View File
@@ -18,6 +18,7 @@ target_link_libraries(DriverPostIterationRPWitnessTest PRIVATE
add_executable(
DriverBugProbesTest
DriverBugProbesTest.cpp
PersistentBufferOrderingProbeTest.cpp
)
target_include_directories(DriverBugProbesTest PRIVATE
@@ -0,0 +1,326 @@
// MobileGL - MobileGL/MG_Test/SelfTest/PersistentBufferOrderingProbeTest.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include <gtest/gtest.h>
#include <MG_Util/SelfTest/PersistentBufferOrderingProbe.h>
#include <map>
#include <set>
using namespace MobileGL;
using namespace MobileGL::MG_Util::SelfTest;
namespace {
// Deferred vertex fetch, not canned ReadPixels answers: a broken mapped destination
// reads its current bytes at Finish instead of the bytes at DrawArrays. ReadPixels also
// drains these jobs, so inserting an early readback into the probe hides the bug here too.
struct FakeDriver {
struct Buffer {
Bool mapped = false;
Bool arena = false;
Bool copied = false;
Int channel = 0;
Vector<Uint8> staging;
};
struct Draw { GLuint fbo, buffer; Int channel; Bool late; };
std::map<GLuint, Buffer> buffers;
std::map<GLuint, GLuint> vaoBuffers;
std::map<GLuint, Int> colors;
Vector<Draw> draws;
std::set<GLuint> live;
std::map<GLenum, GLint> state = {
{GL_CURRENT_PROGRAM, 1}, {GL_VERTEX_ARRAY_BINDING, 2}, {GL_ARRAY_BUFFER, 3},
{GL_COPY_READ_BUFFER, 4}, {GL_COPY_WRITE_BUFFER, 5},
{GL_DRAW_FRAMEBUFFER_BINDING, 6}, {GL_READ_FRAMEBUFFER_BINDING, 7},
{GL_TEXTURE_BINDING_2D, 8}, {GL_PIXEL_PACK_BUFFER, 9},
{GL_PACK_ALIGNMENT, 8}, {GL_PACK_ROW_LENGTH, 31},
{GL_PACK_SKIP_PIXELS, 4}, {GL_PACK_SKIP_ROWS, 5}};
std::map<GLenum, GLboolean> enabled = {{GL_BLEND, GL_TRUE}, {GL_SCISSOR_TEST, GL_TRUE},
{GL_SAMPLE_MASK, GL_TRUE}, {GL_RASTERIZER_DISCARD, GL_TRUE}};
std::array<GLint, 4> viewport = {3, 4, 5, 6};
std::array<GLfloat, 4> clear = {.25f, .5f, .75f, 0};
std::array<GLboolean, 4> mask = {GL_FALSE, GL_TRUE, GL_FALSE, GL_TRUE};
GLuint next = 100;
GLenum error = GL_NO_ERROR;
Bool extension = true, corruptSubData = false, corruptCopy = false;
Bool corruptUnmapped = false, corruptSerialized = false;
Bool failMap = false, failAllocation = false, failFramebuffer = false;
Int failReadbackAt = 0, readbacks = 0;
Uint arenaAllocations = 0;
Uint8 pointerSentinel = 0;
GLuint Create() { live.insert(next); return next++; }
void Generate(GLsizei count, GLuint* ids) { for (Int i = 0; i < count; ++i) ids[i] = Create(); }
void Delete(GLsizei count, const GLuint* ids) {
for (Int i = 0; i < count; ++i) {
live.erase(ids[i]);
buffers.erase(ids[i]);
}
}
GLint Get(GLenum name) const {
const auto found = state.find(name);
return found == state.end() ? 0 : found->second;
}
static Int Channel(const void* data) {
GLfloat color[3];
std::memcpy(color, static_cast<const Uint8*>(data) + 2 * sizeof(GLfloat), sizeof(color));
return color[0] > .5f ? 0 : color[1] > .5f ? 1 : 2;
}
void Finish() {
for (const auto& draw : draws) {
const auto& buffer = buffers.at(draw.buffer);
colors[draw.fbo] = draw.late ? buffer.channel : draw.channel;
if (buffer.mapped && corruptSerialized) colors[draw.fbo] = (draw.channel + 1) % 3;
}
draws.clear();
}
} driver;
MG_External::GLESFunctionsTable Table() {
MG_External::GLESFunctionsTable gl{};
gl.glGetIntegerv = [](GLenum name, GLint* out) {
if (name == GL_MAJOR_VERSION) *out = 3;
else if (name == GL_MINOR_VERSION) *out = 2;
else if (name == GL_NUM_EXTENSIONS) *out = driver.extension ? 1 : 0;
else if (name == GL_VIEWPORT) std::copy(driver.viewport.begin(), driver.viewport.end(), out);
else if (name == GL_ARRAY_BUFFER_BINDING) *out = driver.Get(GL_ARRAY_BUFFER);
else if (name == GL_PIXEL_PACK_BUFFER_BINDING) *out = driver.Get(GL_PIXEL_PACK_BUFFER);
else *out = driver.Get(name);
};
gl.glGetBooleanv = [](GLenum, GLboolean* out) { std::copy(driver.mask.begin(), driver.mask.end(), out); };
gl.glGetFloatv = [](GLenum, GLfloat* out) { std::copy(driver.clear.begin(), driver.clear.end(), out); };
gl.glGetStringi = [](GLenum, GLuint) { return reinterpret_cast<const GLubyte*>("GL_EXT_buffer_storage"); };
gl.glGetError = []() { return std::exchange(driver.error, GL_NO_ERROR); };
gl.glIsEnabled = [](GLenum name) -> GLboolean { return driver.enabled[name]; };
gl.glEnable = [](GLenum name) { driver.enabled[name] = GL_TRUE; };
gl.glDisable = [](GLenum name) { driver.enabled[name] = GL_FALSE; };
gl.glCreateShader = [](GLenum) { return driver.Create(); };
gl.glShaderSource = [](GLuint, GLsizei, const GLchar* const*, const GLint*) {};
gl.glCompileShader = [](GLuint) {};
gl.glGetShaderiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; };
gl.glGetShaderInfoLog = [](GLuint, GLsizei, GLsizei*, GLchar* out) { *out = 0; };
gl.glDeleteShader = [](GLuint id) { driver.Delete(1, &id); };
gl.glCreateProgram = []() { return driver.Create(); };
gl.glAttachShader = [](GLuint, GLuint) {};
gl.glLinkProgram = [](GLuint) {};
gl.glGetProgramiv = [](GLuint, GLenum, GLint* out) { *out = GL_TRUE; };
gl.glGetProgramInfoLog = gl.glGetShaderInfoLog;
gl.glDeleteProgram = gl.glDeleteShader;
gl.glUseProgram = [](GLuint id) { driver.state[GL_CURRENT_PROGRAM] = id; };
gl.glGenBuffers = [](GLsizei count, GLuint* ids) { driver.Generate(count, ids); };
gl.glBindBuffer = [](GLenum target, GLuint id) { driver.state[target] = id; };
gl.glBufferStorageEXT = [](GLenum target, GLsizeiptr size, const void*, GLbitfield) {
auto& buffer = driver.buffers[driver.Get(target)];
buffer.arena = size >= 16 * 1024 * 1024;
if (buffer.arena) ++driver.arenaAllocations;
if (driver.failAllocation) driver.error = GL_OUT_OF_MEMORY;
if (!buffer.arena) buffer.staging.resize(size);
};
gl.glBufferData = [](GLenum target, GLsizeiptr size, const void*, GLenum) {
driver.buffers[driver.Get(target)].staging.resize(size);
};
gl.glMapBufferRange = [](GLenum target, GLintptr offset, GLsizeiptr, GLbitfield) -> void* {
if (driver.failMap) return nullptr;
auto& buffer = driver.buffers[driver.Get(target)];
buffer.mapped = true;
return buffer.arena ? &driver.pointerSentinel : buffer.staging.data() + offset;
};
gl.glUnmapBuffer = [](GLenum) -> GLboolean { return GL_TRUE; }; // Preserve allocation history.
gl.glBufferSubData = [](GLenum target, GLintptr offset, GLsizeiptr size, const void* data) {
auto& buffer = driver.buffers[driver.Get(target)];
if (buffer.arena) {
buffer.channel = FakeDriver::Channel(data);
buffer.copied = false;
} else std::memcpy(buffer.staging.data() + offset, data, size);
};
gl.glCopyBufferSubData = [](GLenum read, GLenum write, GLintptr offset, GLintptr, GLsizeiptr) {
auto& source = driver.buffers[driver.Get(read)];
auto& dest = driver.buffers[driver.Get(write)];
dest.channel = FakeDriver::Channel(source.staging.data() + offset);
dest.copied = true;
};
gl.glDeleteBuffers = [](GLsizei count, const GLuint* ids) { driver.Delete(count, ids); };
gl.glGenVertexArrays = gl.glGenBuffers;
gl.glBindVertexArray = [](GLuint id) { driver.state[GL_VERTEX_ARRAY_BINDING] = id; };
gl.glVertexAttribPointer = [](GLuint, GLint, GLenum, GLboolean, GLsizei, const void*) {
driver.vaoBuffers[driver.Get(GL_VERTEX_ARRAY_BINDING)] = driver.Get(GL_ARRAY_BUFFER);
};
gl.glEnableVertexAttribArray = [](GLuint) {};
gl.glDeleteVertexArrays = gl.glDeleteBuffers;
gl.glGenTextures = gl.glGenBuffers;
gl.glBindTexture = [](GLenum, GLuint id) { driver.state[GL_TEXTURE_BINDING_2D] = id; };
gl.glTexStorage2D = [](GLenum, GLsizei, GLenum, GLsizei, GLsizei) {};
gl.glDeleteTextures = gl.glDeleteBuffers;
gl.glGenFramebuffers = gl.glGenBuffers;
gl.glBindFramebuffer = [](GLenum target, GLuint id) {
if (target != GL_READ_FRAMEBUFFER) driver.state[GL_DRAW_FRAMEBUFFER_BINDING] = id;
if (target != GL_DRAW_FRAMEBUFFER) driver.state[GL_READ_FRAMEBUFFER_BINDING] = id;
};
gl.glFramebufferTexture2D = [](GLenum, GLenum, GLenum, GLuint, GLint) {};
gl.glCheckFramebufferStatus = [](GLenum) -> GLenum {
return driver.failFramebuffer ? GL_FRAMEBUFFER_UNSUPPORTED : GL_FRAMEBUFFER_COMPLETE;
};
gl.glDeleteFramebuffers = gl.glDeleteBuffers;
gl.glViewport = [](GLint x, GLint y, GLsizei w, GLsizei h) { driver.viewport = {x, y, w, h}; };
gl.glColorMask = [](GLboolean r, GLboolean g, GLboolean b, GLboolean a) { driver.mask = {r, g, b, a}; };
gl.glClearColor = [](GLfloat r, GLfloat g, GLfloat b, GLfloat a) { driver.clear = {r, g, b, a}; };
gl.glClear = [](GLbitfield) {};
gl.glDrawArrays = [](GLenum, GLint, GLsizei) {
const GLuint id = driver.vaoBuffers.at(driver.Get(GL_VERTEX_ARRAY_BINDING));
const auto& buffer = driver.buffers.at(id);
const Bool late = buffer.mapped ? (buffer.copied ? driver.corruptCopy : driver.corruptSubData)
: driver.corruptUnmapped;
driver.draws.push_back({GLuint(driver.Get(GL_DRAW_FRAMEBUFFER_BINDING)), id, buffer.channel, late});
};
gl.glFinish = []() { driver.Finish(); };
gl.glMemoryBarrier = [](GLbitfield) {};
gl.glPixelStorei = [](GLenum name, GLint value) { driver.state[name] = value; };
gl.glReadPixels = [](GLint, GLint, GLsizei width, GLsizei height, GLenum, GLenum, void* data) {
driver.Finish(); // Models the implicit wait that must NOT occur between subject draws.
if (++driver.readbacks == driver.failReadbackAt) {
driver.error = GL_INVALID_OPERATION;
return;
}
EXPECT_EQ(driver.Get(GL_PIXEL_PACK_BUFFER), 0);
EXPECT_EQ(driver.Get(GL_PACK_ROW_LENGTH), 0);
const Int channel = driver.colors.at(driver.Get(GL_READ_FRAMEBUFFER_BINDING));
auto* pixels = static_cast<Uint8*>(data);
for (Int i = 0; i < width * height; ++i)
for (Int c = 0; c < 4; ++c) pixels[4 * i + c] = c == channel || c == 3 ? 255 : 0;
};
return gl;
}
class PersistentBufferOrderingProbeTest : public ::testing::Test {
protected:
void SetUp() override { driver = FakeDriver{}; }
void TearDown() override { EXPECT_TRUE(driver.live.empty()); EXPECT_TRUE(driver.draws.empty()); }
};
}
TEST_F(PersistentBufferOrderingProbeTest, RequiresExtensionAndCompleteDispatchBeforeAllocating) {
auto gl = Table();
driver.extension = false;
EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported);
driver.extension = true;
gl.glCopyBufferSubData = nullptr;
EXPECT_FALSE(ProbePersistentBufferUpdateOrdering(gl).supported);
EXPECT_EQ(driver.arenaAllocations, 0u);
}
TEST_F(PersistentBufferOrderingProbeTest, OrderedDriverPassesAllUploadsAndRestoresCallerState) {
const auto saved = driver;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
ASSERT_TRUE(measurement.supported);
for (const auto& row : measurement.uploads) {
EXPECT_TRUE(row.unmapped.Passed());
EXPECT_TRUE(row.mapped.Passed());
EXPECT_EQ(row.mapped.frames, 240u); // Three fresh attempts before a negative result.
EXPECT_EQ(row.finishBoth.status, BufferOrderingProbeStatus::NotRun);
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.state, saved.state);
EXPECT_EQ(driver.viewport, saved.viewport);
EXPECT_EQ(driver.clear, saved.clear);
EXPECT_EQ(driver.mask, saved.mask);
for (const auto& [cap, value] : driver.enabled) {
const auto found = saved.enabled.find(cap);
EXPECT_EQ(value, found == saved.enabled.end() ? GL_FALSE : found->second);
}
}
TEST_F(PersistentBufferOrderingProbeTest, DeferredMappedSubDataFetchIsDetectedWithPassingControls) {
driver.corruptSubData = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_TRUE(measurement.uploads[0].Detected());
EXPECT_TRUE(measurement.uploads[0].finishBefore.Passed());
EXPECT_GT(measurement.uploads[0].mapThenUnmap.badFrames, 0u);
EXPECT_GT(measurement.uploads[0].barrierBefore.badFrames, 0u);
EXPECT_FALSE(measurement.uploads[1].Detected());
EXPECT_FALSE(measurement.uploads[2].Detected());
const auto finding = DescribePersistentBufferOrderingBug(measurement);
ASSERT_TRUE(finding);
EXPECT_EQ(finding->verdict, DriverBugVerdict::Unfixable);
EXPECT_NE(finding->detail.find("SubData:"), String::npos);
EXPECT_NE(finding->detail.find("MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1"), String::npos);
}
TEST_F(PersistentBufferOrderingProbeTest, DeferredCopyFetchIsDetectedWithBothStagingSources) {
driver.corruptCopy = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_FALSE(measurement.uploads[0].Detected());
EXPECT_TRUE(measurement.uploads[1].Detected());
EXPECT_TRUE(measurement.uploads[2].Detected());
}
TEST_F(PersistentBufferOrderingProbeTest, PostCollectorIncludesTheMeasuredFinding) {
driver.corruptSubData = true;
const auto findings = CollectGlesKnownDriverBugs(Table());
const auto found = std::find_if(findings.begin(), findings.end(), [](const auto& finding) {
return finding.name == "Persistent-mapped vertex buffers lose upload/draw ordering";
});
ASSERT_NE(found, findings.end());
EXPECT_NE(found->detail.find("never-mapped 0/80"), String::npos);
EXPECT_EQ(found->verdict, DriverBugVerdict::Unfixable);
}
TEST_F(PersistentBufferOrderingProbeTest, CorruptNeverMappedControlCannotAccusePersistentMapping) {
driver.corruptSubData = driver.corruptCopy = driver.corruptUnmapped = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) {
EXPECT_GT(row.unmapped.badFrames, 0u);
EXPECT_EQ(row.mapped.status, BufferOrderingProbeStatus::NotRun);
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, CorruptSerializedControlCannotConfirmOrderingDefect) {
driver.corruptSubData = driver.corruptCopy = driver.corruptSerialized = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) EXPECT_GT(row.finishBoth.badFrames, 0u);
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, FailedMappingIsInconclusiveAndReleasesResources) {
driver.failMap = true;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(measurement.uploads[1].unmapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, AllocationFailureIsInconclusiveAndRestoresBindings) {
driver.failAllocation = true;
const auto saved = driver.state;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
for (const auto& row : measurement.uploads) {
EXPECT_EQ(row.unmapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(row.unmapped.error, GLenum(GL_OUT_OF_MEMORY));
}
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.state, saved);
}
TEST_F(PersistentBufferOrderingProbeTest, ReadbackErrorAfterAMismatchDoesNotProduceAFinding) {
driver.corruptSubData = true;
driver.failReadbackAt = 82; // Eighty clean control readbacks, then one corrupt subject FBO.
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_GT(measurement.uploads[0].mapped.badFrames, 0u);
EXPECT_EQ(measurement.uploads[0].mapped.status, BufferOrderingProbeStatus::Failed);
EXPECT_EQ(measurement.uploads[0].mapped.error, GLenum(GL_INVALID_OPERATION));
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
}
TEST_F(PersistentBufferOrderingProbeTest, IncompleteFramebufferIsInconclusiveAndRestoresBindings) {
driver.failFramebuffer = true;
const auto saved = driver.state;
const auto measurement = ProbePersistentBufferUpdateOrdering(Table());
EXPECT_FALSE(DescribePersistentBufferOrderingBug(measurement));
EXPECT_EQ(driver.arenaAllocations, 0u);
EXPECT_EQ(driver.state, saved);
}
+15
View File
@@ -151,6 +151,16 @@ namespace {
}
EXPECT_NE(line.find("gates["), String::npos) << line;
EXPECT_NE(line.find("tex[emit="), String::npos) << line;
#if MOBILEGL_PIPE_PUSH
// P2's two render-state CSO counters ride the same line, short-named. Push-only:
// the pull build has no CSO to mint and must stay symbol-identical.
EXPECT_NE(line.find("cso[csom="), String::npos) << line;
EXPECT_NE(line.find("csob="), String::npos) << line;
// P3a's persistent-map acquisition attempts ride the same bracket. It is the counter
// the storage-regrow gate reads, so its short name is pinned where an operator's
// grep would break.
EXPECT_NE(line.find("mpr="), String::npos) << line;
#endif
}
// Per-frame fields carry two decimals for the same reason acc/draw does: they are small
@@ -267,6 +277,11 @@ namespace {
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndirectCmd), "stage-indirect-cmd");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block");
#if MOBILEGL_PIPE_PUSH
EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoMints), "render-state-cso-mints");
EXPECT_STREQ(PS::NameOf(PS::CallClass::RenderStateCsoBinds), "render-state-cso-binds");
EXPECT_STREQ(PS::NameOf(PS::CallClass::MapPersistentRoundtrips), "map-persistent-roundtrips");
#endif
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state");
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list");
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytUnitBindingsEpoch), "espryt-unit-bindings-epoch");
+14
View File
@@ -173,6 +173,9 @@ namespace MobileGL::MG_Util::PipeStats {
const char* const kCallClassNames[kCallClassCount] = {
"draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect",
"tex-upload-jobs",
#if MOBILEGL_PIPE_PUSH
"render-state-cso-mints", "render-state-cso-binds", "map-persistent-roundtrips",
#endif
};
const char* const kGateNames[kGateCount] = {
"espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch",
@@ -416,6 +419,17 @@ namespace MobileGL::MG_Util::PipeStats {
line += " box=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadBoxEmissions)]);
line += " rect=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadRectEmissions)]);
line += " jobs=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadJobs)]);
#if MOBILEGL_PIPE_PUSH
// Push-only, like the two counters themselves: in a pull build there is no CSO to
// mint, and a "csom=0 csob=0" that can never be anything else is noise on the one
// line an operator greps.
line += "] cso[csom=" + std::to_string(calls[static_cast<Uint32>(CallClass::RenderStateCsoMints)]);
line += " csob=" + std::to_string(calls[static_cast<Uint32>(CallClass::RenderStateCsoBinds)]);
// P3a's persistent-map acquisition attempts, on the same bracket and for the same
// reason: it is push-only, and a window with an unexpected mpr= is the one number
// that says an adoption is happening per draw rather than per storage definition.
line += " mpr=" + std::to_string(calls[static_cast<Uint32>(CallClass::MapPersistentRoundtrips)]);
#endif
line += "] gates[";
for (Uint32 i = 0; i < kGateCount; ++i) {
if (i != 0) {
+27
View File
@@ -98,6 +98,33 @@ namespace MobileGL::MG_Util::PipeStats {
// Driver upload jobs issued by those emissions: 1 per box emission, N per rect-list
// emission.
TextureUploadJobs,
#if MOBILEGL_PIPE_PUSH
// P2's two, and they are PUSH-ONLY on purpose: a render-state CSO exists only in a
// push build, and the pull build has to stay symbol-identical (G1) - growing this
// enum there would resize the counter arrays, the name table and FormatWindowLine
// for a pair of counters that could never leave zero.
//
// Render-state CSOs MINTED: a pipeline-subset hash that missed the CsoCache and had
// to be created. The Blaze3D blend toggle is the shape this exists to answer for -
// enable/draw/disable/draw forever must mint 2 and then never mint again - and it is
// half of what a P13 retune of the 64-entry capacity reads.
RenderStateCsoMints,
// Render-state CSOs BOUND: one per bind_render_state, mint or reuse. mints/binds is
// the cache's hit rate, and it is the number the CSO content-addressing negative
// control moves.
RenderStateCsoBinds,
// P3a's, and push-only for the same reason as the two above.
//
// EVERY map_persistent EMISSION, i.e. every acquisition ATTEMPT - a mint or a decline
// - because every one of them needs an answer from the resource owner. Counted that
// way on purpose: "round trips actually taken" is 0 by construction in a monolith and
// could never go red, which is not a counter, it is a decoration. Counted as attempts
// the number is identical in both modes, it is exactly "one per storage definition",
// and a regression that acquires per DRAW instead of per definition shows up on the
// first window. Counted at the client emitter, behind the usual Enabled() predicate;
// no timer anywhere.
MapPersistentRoundtrips,
#endif
Count
};
@@ -7,6 +7,7 @@
// End of Source File Header
#include "DriverBugProbes.h"
#include "PersistentBufferOrderingProbe.h"
#include <Config.h>
#include <MG_Util/Debug/Log.h>
@@ -2445,6 +2446,10 @@ namespace MobileGL::MG_Util::SelfTest {
DriverBugVerdict::Unfixable, detail};
}
Optional<DriverBugFinding> ProbePersistentBufferOrderingBug(const GLESFunctionsTable& gl) {
return DescribePersistentBufferOrderingBug(ProbePersistentBufferUpdateOrdering(gl));
}
// The table. One row per known driver bug; see the header for how to add a sibling.
using DriverBugProbeFn = Optional<DriverBugFinding> (*)(const GLESFunctionsTable&);
constexpr DriverBugProbeFn kGlesDriverBugProbes[] = {
@@ -2457,6 +2462,7 @@ namespace MobileGL::MG_Util::SelfTest {
&ProbeLayeredBlitDestinationBug,
&ProbeLocatedIoBlockPayloadBug,
&ProbeCopyImagePacked16FieldOrderBug,
&ProbePersistentBufferOrderingBug,
};
} // namespace
@@ -0,0 +1,413 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.cpp
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#include "PersistentBufferOrderingProbe.h"
#include <MG_Util/Debug/Log.h>
#include <array>
#include <cmath>
#include <cstring>
#include <new>
namespace MobileGL::MG_Util::SelfTest {
namespace {
using MG_External::GLESFunctionsTable;
constexpr GLbitfield kPersistent = 0x0040;
constexpr GLbitfield kCoherent = 0x0080;
constexpr GLbitfield kDynamicStorage = 0x0100;
constexpr GLbitfield kMapFlags = GL_MAP_WRITE_BIT | kPersistent | kCoherent;
constexpr GLsizeiptr kArenaSize = 128 * 1024 * 1024;
constexpr GLintptr kOffset = 96 * 1024 * 1024 + 28;
constexpr GLsizei kSide = 128;
constexpr GLsizei kSlots = 8;
constexpr Int kBatches = 10;
constexpr Int kDraws = 32;
constexpr GLsizei kQuads = 64 * 32;
constexpr Int kAttempts = 3;
constexpr std::array<const char*, 3> kUploadNames = {"SubData", "Copy/persistent staging",
"Copy/SubData staging"};
enum class Shape { Unmapped, Mapped, FinishBefore, FinishBoth, MapThenUnmap, BarrierBefore };
struct Vertex { GLfloat x, y, r, g, b; };
constexpr GLsizeiptr kPayloadSize = kQuads * 6 * sizeof(Vertex);
static_assert(kOffset + kPayloadSize <= kArenaSize);
void DrainErrors(const GLESFunctionsTable& gl) {
for (Int i = 0; i < 32 && gl.glGetError() != GL_NO_ERROR; ++i) {}
}
Bool CanProbe(const GLESFunctionsTable& gl) {
if (!(gl.glGetIntegerv && gl.glGetBooleanv && gl.glGetFloatv && gl.glGetError &&
gl.glGetStringi && gl.glIsEnabled && gl.glEnable && gl.glDisable &&
gl.glCreateShader && gl.glShaderSource && gl.glCompileShader && gl.glGetShaderiv &&
gl.glGetShaderInfoLog && gl.glDeleteShader && gl.glCreateProgram && gl.glAttachShader &&
gl.glLinkProgram && gl.glGetProgramiv && gl.glGetProgramInfoLog && gl.glDeleteProgram &&
gl.glUseProgram && gl.glGenBuffers && gl.glBindBuffer && gl.glBufferStorageEXT &&
gl.glMapBufferRange && gl.glUnmapBuffer && gl.glBufferData && gl.glBufferSubData &&
gl.glCopyBufferSubData && gl.glDeleteBuffers && gl.glGenVertexArrays &&
gl.glBindVertexArray && gl.glVertexAttribPointer && gl.glEnableVertexAttribArray &&
gl.glDeleteVertexArrays && gl.glGenTextures && gl.glBindTexture && gl.glTexStorage2D &&
gl.glDeleteTextures && gl.glGenFramebuffers && gl.glBindFramebuffer &&
gl.glFramebufferTexture2D && gl.glCheckFramebufferStatus && gl.glDeleteFramebuffers &&
gl.glViewport && gl.glColorMask && gl.glClearColor && gl.glClear && gl.glDrawArrays &&
gl.glFinish && gl.glMemoryBarrier && gl.glPixelStorei && gl.glReadPixels)) return false;
DrainErrors(gl);
GLint major = 0, minor = 0, count = 0;
gl.glGetIntegerv(GL_MAJOR_VERSION, &major);
gl.glGetIntegerv(GL_MINOR_VERSION, &minor);
gl.glGetIntegerv(GL_NUM_EXTENSIONS, &count);
if (gl.glGetError() != GL_NO_ERROR || major < 3 || (major == 3 && minor < 1)) return false;
for (GLint i = 0; i < count; ++i) {
const auto* extension = gl.glGetStringi(GL_EXTENSIONS, i);
if (extension && std::strcmp(reinterpret_cast<const char*>(extension),
"GL_EXT_buffer_storage") == 0) return true;
}
return false;
}
// This probe touches no images/SSBO bindings. Keep its scope independent from the
// other POST probes, including pack state and the caller's currently active texture unit.
struct StateScope {
const GLESFunctionsTable& gl;
GLint program = 0, vao = 0, array = 0, copyRead = 0, copyWrite = 0;
GLint drawFbo = 0, readFbo = 0, texture = 0, packBuffer = 0;
GLint viewport[4]{};
GLfloat clear[4]{};
GLboolean colorMask[4]{};
static constexpr std::array<GLenum, 10> enables = {
GL_BLEND, GL_DEPTH_TEST, GL_STENCIL_TEST, GL_CULL_FACE, GL_SCISSOR_TEST,
GL_RASTERIZER_DISCARD, GL_DITHER, GL_SAMPLE_ALPHA_TO_COVERAGE,
GL_SAMPLE_COVERAGE, GL_SAMPLE_MASK};
static constexpr std::array<GLenum, 4> packNames = {
GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_PIXELS, GL_PACK_SKIP_ROWS};
std::array<GLboolean, enables.size()> enabled{};
std::array<GLint, packNames.size()> pack{};
explicit StateScope(const GLESFunctionsTable& api) : gl(api) {
gl.glGetIntegerv(GL_CURRENT_PROGRAM, &program);
gl.glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &vao);
gl.glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &array);
gl.glGetIntegerv(GL_COPY_READ_BUFFER_BINDING, &copyRead);
gl.glGetIntegerv(GL_COPY_WRITE_BUFFER_BINDING, &copyWrite);
gl.glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &drawFbo);
gl.glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &readFbo);
gl.glGetIntegerv(GL_TEXTURE_BINDING_2D, &texture);
gl.glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &packBuffer);
gl.glGetIntegerv(GL_VIEWPORT, viewport);
gl.glGetFloatv(GL_COLOR_CLEAR_VALUE, clear);
gl.glGetBooleanv(GL_COLOR_WRITEMASK, colorMask);
for (SizeT i = 0; i < enables.size(); ++i) enabled[i] = gl.glIsEnabled(enables[i]);
for (SizeT i = 0; i < packNames.size(); ++i) gl.glGetIntegerv(packNames[i], &pack[i]);
}
void Prepare() {
for (auto cap : enables) gl.glDisable(cap);
gl.glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, 0);
for (auto name : packNames) gl.glPixelStorei(name, name == GL_PACK_ALIGNMENT ? 1 : 0);
gl.glViewport(0, 0, kSide, kSide);
gl.glClearColor(0, 0, 0, 1);
}
~StateScope() {
gl.glUseProgram(program);
gl.glBindVertexArray(vao);
gl.glBindBuffer(GL_ARRAY_BUFFER, array);
gl.glBindBuffer(GL_COPY_READ_BUFFER, copyRead);
gl.glBindBuffer(GL_COPY_WRITE_BUFFER, copyWrite);
gl.glBindBuffer(GL_PIXEL_PACK_BUFFER, packBuffer);
gl.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, drawFbo);
gl.glBindFramebuffer(GL_READ_FRAMEBUFFER, readFbo);
gl.glBindTexture(GL_TEXTURE_2D, texture);
gl.glViewport(viewport[0], viewport[1], viewport[2], viewport[3]);
gl.glClearColor(clear[0], clear[1], clear[2], clear[3]);
gl.glColorMask(colorMask[0], colorMask[1], colorMask[2], colorMask[3]);
for (SizeT i = 0; i < enables.size(); ++i) {
if (enabled[i]) gl.glEnable(enables[i]); else gl.glDisable(enables[i]);
}
for (SizeT i = 0; i < packNames.size(); ++i) gl.glPixelStorei(packNames[i], pack[i]);
}
};
struct Resources {
const GLESFunctionsTable& gl;
GLuint program = 0, vao = 0;
std::array<GLuint, kSlots> fbos{}, textures{};
explicit Resources(const GLESFunctionsTable& api) : gl(api) {}
~Resources() {
gl.glDeleteFramebuffers(kSlots, fbos.data());
gl.glDeleteTextures(kSlots, textures.data());
gl.glDeleteVertexArrays(1, &vao);
if (program) gl.glDeleteProgram(program);
}
GLuint Compile(GLenum type, const char* source) {
GLuint shader = gl.glCreateShader(type);
if (!shader) return 0;
gl.glShaderSource(shader, 1, &source, nullptr);
gl.glCompileShader(shader);
GLint compiled = 0;
gl.glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (!compiled) {
char log[512]{};
gl.glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
MGLOG_I("[driver-bug] persistent buffer ordering: shader failed: %s", log);
gl.glDeleteShader(shader);
return 0;
}
return shader;
}
Bool Setup() {
const GLuint vs = Compile(GL_VERTEX_SHADER,
"#version 310 es\nlayout(location=0) in vec2 pos; layout(location=1) in vec3 color;\n"
"out highp vec3 vColor; void main(){gl_Position=vec4(pos,0,1);vColor=color;}\n");
const GLuint fs = Compile(GL_FRAGMENT_SHADER,
"#version 310 es\nprecision highp float; in highp vec3 vColor;\n"
"layout(location=0) out vec4 outColor; void main(){outColor=vec4(vColor,1);}\n");
if (vs && fs) {
program = gl.glCreateProgram();
if (program) {
gl.glAttachShader(program, vs);
gl.glAttachShader(program, fs);
gl.glLinkProgram(program);
}
}
if (vs) gl.glDeleteShader(vs);
if (fs) gl.glDeleteShader(fs);
if (!program) return false;
GLint linked = 0;
gl.glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) {
char log[512]{};
gl.glGetProgramInfoLog(program, sizeof(log), nullptr, log);
MGLOG_I("[driver-bug] persistent buffer ordering: link failed: %s", log);
return false;
}
gl.glUseProgram(program);
gl.glGenVertexArrays(1, &vao);
gl.glBindVertexArray(vao);
gl.glGenFramebuffers(kSlots, fbos.data());
gl.glGenTextures(kSlots, textures.data());
for (Int i = 0; i < kSlots; ++i) {
if (!vao || !fbos[i] || !textures[i]) return false;
gl.glBindTexture(GL_TEXTURE_2D, textures[i]);
gl.glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSide, kSide);
gl.glBindFramebuffer(GL_FRAMEBUFFER, fbos[i]);
gl.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
textures[i], 0);
if (gl.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) return false;
}
return gl.glGetError() == GL_NO_ERROR;
}
};
struct Buffers {
const GLESFunctionsTable& gl;
GLuint arena = 0, staging = 0;
explicit Buffers(const GLESFunctionsTable& api) : gl(api) {}
~Buffers() {
// All normal batches finish before cleanup; also retire a partially queued
// batch on an error path before destroying a mapped staging source.
gl.glFinish();
gl.glDeleteBuffers(1, &arena);
gl.glDeleteBuffers(1, &staging);
}
};
void FillVertices(Vector<Vertex>& vertices, Int channel) {
constexpr std::array<Vertex, 6> quad = {{{-1,-1,0,0,0}, {1,-1,0,0,0}, {1,1,0,0,0},
{-1,-1,0,0,0}, {1,1,0,0,0}, {-1,1,0,0,0}}};
for (SizeT k = 0; k < vertices.size(); ++k) {
auto& v = vertices[k];
v = quad[k % 6];
const SizeT q = k / 6;
v.x = v.x / 64.f - 1.f + (2 * (q % 64) + 1) / 64.f;
v.y = v.y / 32.f - 1.f + (2 * (q / 64) + 1) / 32.f;
v.r = channel == 0 ? 1.f : 0.f;
v.g = channel == 1 ? 1.f : 0.f;
v.b = channel == 2 ? 1.f : 0.f;
}
}
BufferOrderingSample Run(const GLESFunctionsTable& gl, const Resources& resources,
const Vector<Uint8>& seed, Int upload, Shape shape) {
BufferOrderingSample sample;
sample.status = BufferOrderingProbeStatus::Failed;
Buffers buffers(gl);
Vector<Vertex> payload(kQuads * 6);
Vector<Uint8> pixels(kSide * kSide * 4);
DrainErrors(gl);
do {
gl.glGenBuffers(1, &buffers.arena);
if (!buffers.arena) break;
gl.glBindBuffer(GL_ARRAY_BUFFER, buffers.arena);
gl.glBufferStorageEXT(GL_ARRAY_BUFFER, kArenaSize, seed.data(), kMapFlags | kDynamicStorage);
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
if (shape != Shape::Unmapped) {
// Deliberately never dereference the destination pointer. All destination
// writes below are ordered GL commands, with no client mapping accesses.
if (!gl.glMapBufferRange(GL_ARRAY_BUFFER, 0, kArenaSize, kMapFlags)) break;
if (shape == Shape::MapThenUnmap && !gl.glUnmapBuffer(GL_ARRAY_BUFFER)) break;
}
gl.glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(kOffset));
gl.glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
reinterpret_cast<const void*>(kOffset + 2 * sizeof(GLfloat)));
gl.glEnableVertexAttribArray(0);
gl.glEnableVertexAttribArray(1);
void* sourceMap = nullptr;
if (upload != 0) {
gl.glGenBuffers(1, &buffers.staging);
if (!buffers.staging) break;
gl.glBindBuffer(GL_COPY_READ_BUFFER, buffers.staging);
if (upload == 1) {
gl.glBufferStorageEXT(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, kMapFlags);
sourceMap = gl.glMapBufferRange(GL_COPY_READ_BUFFER, 0, kSlots * kPayloadSize, kMapFlags);
if (!sourceMap) break;
} else {
gl.glBufferData(GL_COPY_READ_BUFFER, kSlots * kPayloadSize, nullptr, GL_STREAM_DRAW);
}
gl.glBindBuffer(GL_COPY_WRITE_BUFFER, buffers.arena);
}
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
for (Int batch = 0; batch < kBatches; ++batch) {
for (Int slot = 0; slot < kSlots; ++slot) {
FillVertices(payload, (batch * kSlots + slot) % 3);
if (shape == Shape::FinishBefore || shape == Shape::FinishBoth) gl.glFinish();
if (shape == Shape::BarrierBefore) gl.glMemoryBarrier(GL_ALL_BARRIER_BITS);
if (upload == 0) {
gl.glBufferSubData(GL_ARRAY_BUFFER, kOffset, kPayloadSize, payload.data());
} else {
// No slot is reused until the entire batch has finished on the GPU.
if (upload == 1) {
std::memcpy(static_cast<Uint8*>(sourceMap) + slot * kPayloadSize,
payload.data(), kPayloadSize);
} else {
gl.glBufferSubData(GL_COPY_READ_BUFFER, slot * kPayloadSize,
kPayloadSize, payload.data());
}
gl.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER,
slot * kPayloadSize, kOffset, kPayloadSize);
}
if (shape == Shape::FinishBoth) gl.glFinish();
gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]);
gl.glClear(GL_COLOR_BUFFER_BIT);
for (Int draw = 0; draw < kDraws; ++draw) gl.glDrawArrays(GL_TRIANGLES, 0, kQuads * 6);
}
// No readback/Finish between subject update/draw pairs. Early readback
// would hide precisely the old-reader/new-writer overlap being tested.
gl.glFinish();
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
for (Int slot = 0; slot < kSlots; ++slot) {
gl.glBindFramebuffer(GL_FRAMEBUFFER, resources.fbos[slot]);
gl.glReadPixels(0, 0, kSide, kSide, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data());
sample.error = gl.glGetError();
if (sample.error != GL_NO_ERROR) break;
const Int channel = (batch * kSlots + slot) % 3;
Uint bad = 0;
for (Int pixel = 0; pixel < kSide * kSide; ++pixel) {
for (Int c = 0; c < 3; ++c) {
const Int expected = c == channel ? 255 : 0;
if (std::abs(Int(pixels[pixel * 4 + c]) - expected) > 8) ++bad;
}
}
++sample.frames;
if (bad != 0) ++sample.badFrames;
sample.badComponents += bad;
}
if (sample.error != GL_NO_ERROR) break;
}
if (sample.error == GL_NO_ERROR && sample.frames == kBatches * kSlots)
sample.status = BufferOrderingProbeStatus::Complete;
} while (false);
if (sample.error == GL_NO_ERROR) sample.error = gl.glGetError();
return sample;
}
String Describe(const BufferOrderingSample& sample) {
if (sample.status == BufferOrderingProbeStatus::NotRun) return "not run";
if (sample.status == BufferOrderingProbeStatus::Failed)
return format("inconclusive (GL error 0x{:x}, {} readbacks)", sample.error, sample.frames);
return format("{}/{} bad FBOs ({} components)", sample.badFrames, sample.frames, sample.badComponents);
}
String DescribeUpload(const BufferOrderingUploadMeasurement& row, Int upload) {
return format("{}: mapped {}, never-mapped {}, Finish-before {}, Finish-both {}, "
"map-then-unmap {}, barrier-before {}", kUploadNames[upload], Describe(row.mapped),
Describe(row.unmapped), Describe(row.finishBefore), Describe(row.finishBoth),
Describe(row.mapThenUnmap), Describe(row.barrierBefore));
}
} // namespace
PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering(const GLESFunctionsTable& gl) try {
PersistentBufferOrderingMeasurement measurement;
if (!CanProbe(gl)) return measurement;
measurement.supported = true;
StateScope state(gl);
state.Prepare();
Resources resources(gl);
if (gl.glGetError() != GL_NO_ERROR || !resources.Setup()) {
for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed;
MGLOG_I("[driver-bug] persistent buffer ordering: setup failed; inconclusive");
return measurement;
}
Vector<Uint8> seed(kArenaSize, 0);
for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) {
auto& row = measurement.uploads[upload];
row.unmapped = Run(gl, resources, seed, upload, Shape::Unmapped);
if (row.unmapped.Passed()) {
// A single allocation can miss on Mali. Stop once a mismatch is measured,
// otherwise retry with fresh storage rather than treating one pass as proof.
for (Int attempt = 0; attempt < kAttempts; ++attempt) {
const auto sample = Run(gl, resources, seed, upload, Shape::Mapped);
row.mapped.status = sample.status;
row.mapped.error = sample.error;
row.mapped.frames += sample.frames;
row.mapped.badFrames += sample.badFrames;
row.mapped.badComponents += sample.badComponents;
if (sample.status != BufferOrderingProbeStatus::Complete || sample.badFrames) break;
}
if (row.mapped.status == BufferOrderingProbeStatus::Complete && row.mapped.badFrames) {
row.finishBoth = Run(gl, resources, seed, upload, Shape::FinishBoth);
row.finishBefore = Run(gl, resources, seed, upload, Shape::FinishBefore);
row.mapThenUnmap = Run(gl, resources, seed, upload, Shape::MapThenUnmap);
row.barrierBefore = Run(gl, resources, seed, upload, Shape::BarrierBefore);
}
}
MGLOG_I("[driver-bug] persistent buffer ordering: %s; %s", DescribeUpload(row, upload).c_str(),
row.Detected() ? "detected" : "not detected or inconclusive");
}
return measurement;
} catch (const std::bad_alloc&) {
// The CPU initializer is arena-sized too. An allocation failure must not discard
// the rest of the POST report or turn a partially sampled case into a finding.
MGLOG_I("[driver-bug] persistent buffer ordering: host allocation failed; inconclusive");
PersistentBufferOrderingMeasurement measurement;
measurement.supported = true;
for (auto& row : measurement.uploads) row.unmapped.status = BufferOrderingProbeStatus::Failed;
return measurement;
}
Optional<DriverBugFinding> DescribePersistentBufferOrderingBug(
const PersistentBufferOrderingMeasurement& measurement) {
String detail;
for (Int upload = 0; upload < Int(measurement.uploads.size()); ++upload) {
if (!measurement.uploads[upload].Detected()) continue;
if (!detail.empty()) detail += "; ";
detail += DescribeUpload(measurement.uploads[upload], upload);
}
if (detail.empty()) return std::nullopt;
detail += ". A 128 MiB immutable vertex destination was mapped WRITE|PERSISTENT|COHERENT, "
"but never accessed through its client pointer. Queued uploads/draws corrupt vertex data; "
"identical never-mapped and Finish-before-and-after controls pass. "
"This POST does not enable a workaround. MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION=1 "
"avoids automatic arena adoption; explicit application mappings remain separate. "
"FBO counts describe this bounded stress probe, not application flicker frequency.";
return DriverBugFinding{"Persistent-mapped vertex buffers lose upload/draw ordering",
DriverBugVerdict::Unfixable, Move(detail)};
}
} // namespace MobileGL::MG_Util::SelfTest
@@ -0,0 +1,66 @@
// MobileGL - MobileGL/MG_Util/SelfTest/PersistentBufferOrderingProbe.h
// Copyright (c) 2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include "DriverBugProbes.h"
#include <array>
namespace MobileGL::MG_Util::SelfTest {
enum class BufferOrderingProbeStatus : Uint8 { NotRun, Complete, Failed };
struct BufferOrderingSample {
BufferOrderingProbeStatus status = BufferOrderingProbeStatus::NotRun;
Uint frames = 0; // Independent FBO readbacks, not draw calls or application frames.
Uint badFrames = 0;
Uint badComponents = 0;
GLenum error = GL_NO_ERROR;
Bool Passed() const { return status == BufferOrderingProbeStatus::Complete && badFrames == 0; }
};
struct BufferOrderingUploadMeasurement {
BufferOrderingSample unmapped;
BufferOrderingSample mapped;
BufferOrderingSample finishBefore;
BufferOrderingSample finishBoth;
BufferOrderingSample mapThenUnmap;
BufferOrderingSample barrierBefore;
Bool Detected() const {
return unmapped.Passed() && finishBoth.Passed() &&
mapped.status == BufferOrderingProbeStatus::Complete && mapped.badFrames != 0;
}
};
struct PersistentBufferOrderingMeasurement {
Bool supported = false;
// SubData; CopyBufferSubData from coherent persistent staging; CopyBufferSubData
// from ordinary SubData staging. Each has its OWN otherwise-identical controls.
std::array<BufferOrderingUploadMeasurement, 3> uploads;
};
// POST-only: native GLES calls, no MobileGL buffers, renderer-name rules or config changes.
// The Mali r54p1 finding: updating an immutable vertex arena that has been persistently
// mapped can corrupt queued draws even when the application never accesses that mapping.
// Queue eight update/draw pairs into separate FBOs BEFORE any Finish/readback, then check
// every pixel of both old and new draws. Staging slots never overlap while in flight.
//
// Each upload runs a never-mapped control with identical storage flags. Try up to three
// fresh mapped allocations to catch intermittent failures. On corruption, measure explicit
// waits, map-then-unmap and a barrier as diagnostics. Only a passing never-mapped AND
// Finish-before-and-after control permits a finding. Setup/GL failures are inconclusive.
// Explicit allocations are one 128 MiB arena, its initializer, and small staging/FBOs;
// allocations, batches and draws are bounded. Every touched GL state is restored.
PersistentBufferOrderingMeasurement ProbePersistentBufferUpdateOrdering(
const MG_External::GLESFunctionsTable& gl);
// Used by the POST collector. A report never labels an inconclusive sample as a bug.
Optional<DriverBugFinding> DescribePersistentBufferOrderingBug(
const PersistentBufferOrderingMeasurement& measurement);
} // namespace MobileGL::MG_Util::SelfTest
+1 -1
View File
@@ -62,7 +62,7 @@ val releaseSigningReady = signingStoreFile.exists()
val debuggableRelease = (findProperty("mobilegl.debuggableRelease") ?: "false").toString().toBoolean()
val mobileGlVersionMajor = 26
val mobileGlVersionMinor = 8
val mobileGlVersionMinor = 9
val mobileGlGitShortHash = runGit("rev-parse", "--short=7", "HEAD") ?: "nogit"
val mobileGlMonthlyRevision = runGit(
"rev-list",
@@ -5,6 +5,22 @@
#include <utility>
#include <dlfcn.h>
// CLOCK_THREAD_CPUTIME_ID is POSIX and present on Linux and on every Android API this replays
// on; the guard exists so the desktop CLI still builds where it is not, and so that "no CPU
// series" is a compile-time fact rather than a silently-zero column.
//
// <time.h>, not <ctime>: clock_gettime, CLOCK_THREAD_CPUTIME_ID and struct timespec are POSIX
// names, and only <time.h> is required to put them at global scope - <ctime> guarantees the C++
// subset in namespace std and leaves the rest to the implementation. glibc and bionic both happen
// to provide them either way; this file is built for both by two different toolchains, so it asks
// for the header that actually promises what it uses.
#if defined(__unix__) || defined(__linux__) || defined(__ANDROID__) || defined(__APPLE__)
#include <time.h>
#define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 1
#else
#define MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK 0
#endif
namespace mobilegl_trace {
namespace benchmark {
namespace {
@@ -21,6 +37,23 @@ GlFinishFn gGlFinish = nullptr;
Clock::time_point gStart;
Clock::time_point gLastBoundary;
std::vector<double> gFrameMs;
std::vector<double> gFrameCpuMs;
double gLastBoundaryCpuMs = 0.0;
// Milliseconds of CPU time this thread has consumed, or -1 where the clock does not exist.
// Negative once means negative always, so End() reports an EMPTY cpu series rather than a
// column of zeroes.
double ThreadCpuMs() {
#if MOBILEGL_TRACE_HAVE_THREAD_CPU_CLOCK
struct timespec now;
if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &now) != 0) {
return -1.0;
}
return static_cast<double>(now.tv_sec) * 1000.0 + static_cast<double>(now.tv_nsec) / 1e6;
#else
return -1.0;
#endif
}
// Same resolution order the glws layers use for MobileGL's entry points: the replay driver
// already dlopen()ed the library with RTLD_GLOBAL before retrace started, so RTLD_NOLOAD
@@ -49,11 +82,19 @@ GlFinishFn ResolveGlFinish() {
void Begin(bool finishEachFrame) {
gFrameMs.clear();
gFrameMs.reserve(kFrameReserve);
gFrameCpuMs.clear();
gFrameCpuMs.reserve(kFrameReserve);
gFinishEachFrame = finishEachFrame;
gResolvedGlFinish = false;
gGlFinish = nullptr;
// Wall baseline FIRST, CPU baseline second - the same order OnFrameBoundary reads them in,
// and for the same reason. Frame 0's CPU interval then sits strictly inside its wall interval,
// so whatever this function costs between the two readings lands in the wall number where it
// can be seen, instead of inflating the CPU number where it cannot. Taken the other way round
// (as this was), frame 0 alone reported a CPU delta biased upward against its own wall delta.
gStart = Clock::now();
gLastBoundary = gStart;
gLastBoundaryCpuMs = ThreadCpuMs();
gEnabled = true;
}
@@ -72,8 +113,16 @@ void OnFrameBoundary() {
gGlFinish();
}
}
// The CPU reading is taken FIRST and the wall reading second, so the wall delta contains the
// cost of the extra syscall rather than the CPU delta hiding inside it: an inflated wall
// number is visible, a deflated CPU number is not.
const double cpuNow = ThreadCpuMs();
const Clock::time_point now = Clock::now();
gFrameMs.push_back(std::chrono::duration<double, std::milli>(now - gLastBoundary).count());
if (cpuNow >= 0.0 && gLastBoundaryCpuMs >= 0.0) {
gFrameCpuMs.push_back(cpuNow - gLastBoundaryCpuMs);
}
gLastBoundaryCpuMs = cpuNow;
gLastBoundary = now;
}
@@ -87,6 +136,13 @@ Report End() {
report.totalSeconds = std::chrono::duration<double>(Clock::now() - gStart).count();
report.frameMs = std::move(gFrameMs);
gFrameMs.clear();
// Only hand back a CPU series that lines up frame-for-frame with the wall series. A short
// one would be a clock that started failing mid-run, and silently re-indexing it against
// frameMs would put frame N's wall time next to frame N+k's CPU time.
if (gFrameCpuMs.size() == report.frameMs.size()) {
report.frameCpuMs = std::move(gFrameCpuMs);
}
gFrameCpuMs.clear();
return report;
}
@@ -11,6 +11,23 @@ namespace benchmark {
//
// Retrace runs --singlethread, so all of this is deliberately plain globals: Begin(),
// OnFrameBoundary() and End() are only ever reached from the one retrace thread.
//
// That single-threadedness is also what makes the SECOND series below sound. Beside the wall
// clock, every frame boundary reads CLOCK_THREAD_CPUTIME_ID - the CPU time consumed by THIS
// thread - and because the retrace loop is the only thread that ever gets here, that number is
// the client-side CPU cost of the frame and nothing else. It is the metric the disaggregation
// GO/NO-GO hangs on (ROADMAP.md: per-thread CPU p50/p99, not wall time), and before P2 the tree
// had no first-party collector for it at all: no CLOCK_THREAD_CPUTIME_ID, no getrusage, no
// /proc/self/task anywhere under MobileGL/, tools/, android-plugin/ or scripts/. Collecting it
// here costs one extra clock_gettime per frame, needs no root, no profiler, no debuggable build
// and no sampling, and - unlike a timer inside the library - commits no instrumentation to a hot
// path.
//
// Wall time and CPU time answer different questions and both are kept: with --benchmark-no-finish
// the wall series still contains everything the thread WAITED for (driver submit, the compositor,
// a fence), while the CPU series contains only what it EXECUTED. A change that moves work off the
// retrace thread shows up as the two series diverging, which is exactly the confusion a single
// number invites.
// Arms timing for the retrace that is about to run.
//
@@ -32,6 +49,12 @@ void OnFrameBoundary();
struct Report {
// Wall time of every completed frame, in milliseconds.
std::vector<double> frameMs;
// Thread CPU time of every completed frame, in milliseconds, in the SAME ORDER and with the
// same length as frameMs - the two are pushed together at one frame boundary, so index i is
// one frame in both. Empty when the platform has no CLOCK_THREAD_CPUTIME_ID, which is the
// one honest reading of "this run collected no CPU series"; a vector of zeroes would be
// indistinguishable from a frame that genuinely burned no CPU.
std::vector<double> frameCpuMs;
// Begin() to End(), in seconds. Covers trace parsing and the leading partial frame too,
// which is why it is reported next to the per-frame statistics rather than derived from
// them.
@@ -827,6 +827,42 @@ std::string BenchmarkResultPath(const Request& request) {
: request.benchmarkResultPath;
}
// mean / median / nearest-rank p95 over the trailing `tail` entries of one per-frame series.
//
// Split out of SummarizeBenchmark rather than duplicated, because the wall series and the CPU
// series have to be reduced IDENTICALLY or the delta between them stops meaning anything: the same
// tail window, the same median rule for an even count, the same nearest-rank p95 (so the reported
// value is always an observed frame, never an interpolation).
struct SeriesSummary {
double meanMs = -1.0;
double medianMs = -1.0;
double p95Ms = -1.0;
};
SeriesSummary SummarizeSeries(const std::vector<double>& series, std::size_t tail) {
SeriesSummary summary;
if (series.empty() || tail == 0 || tail > series.size()) {
return summary;
}
std::vector<double> window(series.end() - static_cast<std::ptrdiff_t>(tail), series.end());
double sum = 0.0;
for (double value : window) {
sum += value;
}
summary.meanMs = sum / static_cast<double>(tail);
std::sort(window.begin(), window.end());
summary.medianMs =
(tail % 2 == 1) ? window[tail / 2] : 0.5 * (window[tail / 2 - 1] + window[tail / 2]);
// Nearest-rank p95, so the reported value is always an observed frame time.
std::size_t rank = static_cast<std::size_t>(std::ceil(0.95 * static_cast<double>(tail)));
if (rank == 0) {
rank = 1;
}
summary.p95Ms = window[rank - 1];
return summary;
}
// Folds the recorded frame times into the headline numbers. Everything but totalSeconds and
// the frame count is computed over the trailing benchmarkTailFrames frames only.
void SummarizeBenchmark(const Request& request, const benchmark::Report& report, Result& result) {
@@ -843,25 +879,20 @@ void SummarizeBenchmark(const Request& request, const benchmark::Report& report,
std::min(static_cast<std::size_t>(requestedTail), report.frameMs.size());
result.benchmarkTailFrames = static_cast<int>(tail);
std::vector<double> window(report.frameMs.end() - static_cast<std::ptrdiff_t>(tail),
report.frameMs.end());
double sum = 0.0;
for (double frameMs : window) {
sum += frameMs;
}
result.benchmarkMeanMs = sum / static_cast<double>(tail);
std::sort(window.begin(), window.end());
result.benchmarkMedianMs = (tail % 2 == 1)
? window[tail / 2]
: 0.5 * (window[tail / 2 - 1] + window[tail / 2]);
// Nearest-rank p95, so the reported value is always an observed frame time.
std::size_t rank = static_cast<std::size_t>(std::ceil(0.95 * static_cast<double>(tail)));
if (rank == 0) {
rank = 1;
}
result.benchmarkP95Ms = window[rank - 1];
const SeriesSummary wall = SummarizeSeries(report.frameMs, tail);
result.benchmarkMeanMs = wall.meanMs;
result.benchmarkMedianMs = wall.medianMs;
result.benchmarkP95Ms = wall.p95Ms;
result.benchmarkFps = result.benchmarkMeanMs > 0.0 ? 1000.0 / result.benchmarkMeanMs : -1.0;
// The CPU series is the same length as the wall series or it is empty (trace_benchmark.cpp
// refuses to hand back a partial one), so the same tail window applies unchanged. When it is
// empty the three CPU fields stay at -1, which is what the platform having no per-thread CPU
// clock looks like - and is not the same reading as a genuine 0.0.
const SeriesSummary cpu = SummarizeSeries(report.frameCpuMs, tail);
result.benchmarkMeanCpuMs = cpu.meanMs;
result.benchmarkMedianCpuMs = cpu.medianMs;
result.benchmarkP95CpuMs = cpu.p95Ms;
}
bool WriteBenchmarkJson(const Request& request,
@@ -886,6 +917,9 @@ bool WriteBenchmarkJson(const Request& request,
file << " \"medianFrameMs\": " << result.benchmarkMedianMs << ",\n";
file << " \"p95FrameMs\": " << result.benchmarkP95Ms << ",\n";
file << " \"fps\": " << result.benchmarkFps << ",\n";
file << " \"meanFrameCpuMs\": " << result.benchmarkMeanCpuMs << ",\n";
file << " \"medianFrameCpuMs\": " << result.benchmarkMedianCpuMs << ",\n";
file << " \"p95FrameCpuMs\": " << result.benchmarkP95CpuMs << ",\n";
file << " \"frameTimesMs\": [";
for (std::size_t i = 0; i < report.frameMs.size(); ++i) {
if (i > 0) {
@@ -893,6 +927,18 @@ bool WriteBenchmarkJson(const Request& request,
}
file << report.frameMs[i];
}
file << "],\n";
// The WHOLE per-frame CPU array, beside the whole per-frame wall array. This is what makes
// p99 - and any other percentile a later question wants - a host-side computation over an
// artefact that already exists, instead of a device change. Empty when this platform has no
// per-thread CPU clock; an empty array and an array of zeroes are different claims.
file << " \"frameCpuTimesMs\": [";
for (std::size_t i = 0; i < report.frameCpuMs.size(); ++i) {
if (i > 0) {
file << ", ";
}
file << report.frameCpuMs[i];
}
file << "]\n";
file << "}\n";
return static_cast<bool>(file);
@@ -965,6 +1011,9 @@ bool WriteResultJson(const Request& request, const Result& result) {
file << " \"benchmarkMeanFrameMs\": " << result.benchmarkMeanMs << ",\n";
file << " \"benchmarkMedianFrameMs\": " << result.benchmarkMedianMs << ",\n";
file << " \"benchmarkP95FrameMs\": " << result.benchmarkP95Ms << ",\n";
file << " \"benchmarkMeanFrameCpuMs\": " << result.benchmarkMeanCpuMs << ",\n";
file << " \"benchmarkMedianFrameCpuMs\": " << result.benchmarkMedianCpuMs << ",\n";
file << " \"benchmarkP95FrameCpuMs\": " << result.benchmarkP95CpuMs << ",\n";
file << " \"benchmarkFps\": " << result.benchmarkFps << "\n";
} else {
file << "\n";
@@ -1045,6 +1094,9 @@ Result RunTraceReplay(const Request& request) {
<< ", meanMs=" << result.benchmarkMeanMs
<< ", medianMs=" << result.benchmarkMedianMs
<< ", p95Ms=" << result.benchmarkP95Ms
<< ", meanCpuMs=" << result.benchmarkMeanCpuMs
<< ", medianCpuMs=" << result.benchmarkMedianCpuMs
<< ", p95CpuMs=" << result.benchmarkP95CpuMs
<< ", fps=" << result.benchmarkFps
<< ", benchmarkResultPath=" << result.benchmarkResultPath;
result.message = message.str();
@@ -90,6 +90,17 @@ struct Result {
double benchmarkMedianMs = -1.0;
double benchmarkP95Ms = -1.0;
double benchmarkFps = -1.0;
// The same three statistics over the retrace thread's CPU time instead of wall time, and the
// reason the CPU series is collected at all: the disaggregation GO/NO-GO is a per-thread CPU
// question, not a frame-rate one. Left at -1 when the platform has no per-thread CPU clock,
// which is distinguishable from a real 0.0.
//
// Only mean/median/p95 stop here. p99 - which is half of what the paired A/B publishes - is
// computed HOST-SIDE from the full frameCpuTimesMs[] array in benchmark.json, so asking for a
// different percentile later needs no device change and no reflash.
double benchmarkMeanCpuMs = -1.0;
double benchmarkMedianCpuMs = -1.0;
double benchmarkP95CpuMs = -1.0;
};
Result RunTraceReplay(const Request& request);

Some files were not shown because too many files have changed in this diff Show More