Compare commits

..
Author SHA1 Message Date
rereview 8c458cd594 [Fix, Test] (clientfb): heal a texture's record from its first set_texture_params - a texture born while the family was not live (the context's default textures are constructed before the backend registers its consumer) has no record, so the application's first glTexParameter* on texture 0 was refused, silently latched away before m-1 and the retrace census's one residual after it went loud; the params path now publishes the create, the storage if the texture has any, and then the parameters, the respecify path's own self-heal shape 2026-09-09 00:43:38 -04:00
rereview c2c6a655ea [Fix, Docs] (MG_Pipe, clientfb): latch set_texture_params on acceptance, and two comments - the emitter advanced its version latch before the applier answered, so a refused record (no record for the handle, no consumer) was not re-sent until the next glTexParameter* moved a version (final review m-1, audit F-7): the applier now returns whether the record took the parameters and the emitter latches on true, counting and naming a refusal; Config.h's mask table gains the fourth D-K2 row (0x400 requires 0x800), and MGPipeTypes.h's metadata paragraph says what the applier does (replaces the descriptor whole with the client's sticky OR) instead of "ORed, never replaced" 2026-09-09 00:16:35 -04:00
rereview 9f60aadc1d [Fix, Test] (clientsp, MG_Pipe, Espryt): produce kMGPipeBindSampler and kMGPipeBindShaderImage where D-A4 places them - nothing set either bit, so ImageBindableHint was always 0, the metadata respecify had no live trigger and the remint pull the hint prevents was neither prevented nor counted (final review M-A); the sampler-view resolution notes SAMPLER, glBindImageTexture's state setter notes SHADER_IMAGE at the bind (so the hint precedes the first sync) and the image walk notes it too, both through a contract door since neither may include TextureEmit.h, and Espryt counts every re-mint of storage it already held as tex-remint-pulls (trp= on the stats line, ROADMAP open question 2's number) 2026-09-09 00:09:30 -04:00
rereview a690032f85 [Fix, Test] (MG_Pipe, clientfb, clientsp): retire every emitter's entry at the object's death - the six death helpers freed the slot and told no emitter, so a dead-but-unrecycled texture handle still resolved to the freed ITextureObject* (the allocator's generation moves only at the next hand-out) and the drain list kept the level: glTexImage2D; glDeleteTextures; <any verb> called a virtual on freed memory from the next validate point (final review C-2); the helpers now forward to the texture, renderbuffer, framebuffer, sampler-view and shader-CSO emitters between the wire delete and the free, ResolveTexture refuses a dead slot loudly on IsLive, the sticky-mask producers stamp the generation they write under, and the delete-then-use sequence is pinned for every kind, for a recycled slot, and under MALLOC_PERTURB_ on both backends 2026-09-08 23:59:48 -04:00
rereview 173f1dd273 [Fix, Test] (clientfb, MG_Pipe): pass the level a texture respecify redefines - every per-level glTexImage*D and glGenerateMipmap grow took the applier's whole-resource arm and dropped the texture's every pending upload, including a level the applier had accepted at an earlier verb with the client's dirty flag already clear, so L0; draw(other); L1; draw(T) read a black level 0 on the handle arm (final review C-1); the storage entry points now state the scope (one level, a chain cut, the whole resource), the emitter builds wire's MGPRespecifiedLevel with the drain's packed target, a per-level call is never deduped on the descriptor, and the applier drops a named level whether or not the descriptor moved 2026-09-08 23:55:44 -04:00
rereview a38bdab4e2 [Tools] (trace_replay): let a caller that installed the APK itself skip the harness install - ColorOS confirms every adb install with a dialog whose tap drops adb mid-stream, so the install has to settle outside a run 2026-09-08 21:39:37 -04:00
rereview 6035c9d7f8 [Fix] (Espryt, Integration): compile the in-place storage-redefinition generation bump under MOBILEGL_PIPE_PUSH - it is Espryt code the pull build shares and G1 keeps the pull library byte-identical to the P4a baseline (0/0/2/0 on 419f9941), so the pull build keeps the pre-P4a hole until the bump lands on dev on its own and the F-3 texture cases decline by name there 2026-09-08 21:11:11 -04:00
rereview 419f9941b5 [Fix] (Espryt): re-sync a framebuffer's cross-object masks after an attachment's storage is redefined in place - a mutable texture regenerated on its driver id or a renderbuffer re-storaged on its moved neither the FBO memo's frontend versions nor the backend-id generation, so the pre-handle arm kept the widening masks of the storage the object was attached with; the renderbuffer half of the F-3 scenario asserts on the handle arm only, where the resource record closes D-D2 (found by the F-3 scenarios on the 0x1ff, 0 and pull lanes) 2026-09-08 20:50:25 -04:00
rereview 72b4c91a45 [Docs] (Pipe): the record-field -> setter -> shutter table for the seven P4a dirty bits at the shutter block, and the bit-11 note that an attached object's storage redefinition moves it 2026-09-08 20:29:55 -04:00
rereview ccb7b6b331 [Fix, Test] (Espryt): drive a unit's sampler through the CSO's own twin on the handle arm - the record arm looked a content-addressed handle up in the identity-keyed registry and could never hit, so only the pre-handle program pass ever put a glBindSampler'd object on the driver (P4a seam F-4) 2026-09-08 20:29:24 -04:00
rereview e4579ee80e [Fix, Test] (Pipe): mix the program identity into the shader-image shutter's plain arm - the per-program counter is equal across programs and against no program, so a switch never moved the image window and a buffer image never reached set_shader_images (P4a seam F-2, esprytdraw SD-4) 2026-09-08 20:28:56 -04:00
rereview 0d01405cf8 [Fix, Test] (Pipe): mix the program in use and the params aggregate into the sampler-view shutter - set_sampler_views is resolved for the current program and a glUseProgram alone never re-emitted it, so a texture bound to an empty slot under one program was never synced for the next (P4a seam F-1 / F-1b) 2026-09-08 20:28:29 -04:00
rereview dcc31e95ca [Fix, Test] (MG_State, Integration): move the framebuffer aggregate from a texture's or renderbuffer's storage definition - set_framebuffer_state inlines an attachment's format and a respecify while attached left Espryt's handle arm answering its four cross-object masks from the stale copy (P4a seam F-3) 2026-09-08 20:15:55 -04:00
rereview 4678519f99 [Fix] (Test): warm the whole content space before the SamplerCso leak baseline, so the case measures the death path and not the content-addressed cache filling up 2026-09-08 19:08:12 -04:00
Swung0x48 4614abb9c6 [Test] (Pipe): erase only the cube face a respecify names, not every pending upload that shares its level 2026-09-08 19:08:12 -04:00
Swung0x48 7107d0f47c [Test] (Pipe): pin that the applier's no-consumer belt never fires behind the client's gate, on the backend that has a consumer and on the one that has none 2026-09-08 19:08:12 -04:00
Swung0x48 972dd811d7 [Test] (Espryt): take G9's reading while the texture is still attachment-only - the applier's params record, Espryt's applied value and the absence of a sampler view, before the sample that would repair all three 2026-09-08 19:08:12 -04:00
Swung0x48 f9c0e7ec43 [Fix] (Test): read the DIRECTION of the subsystem refusal, so the two dependency lanes stop accepting each other's sentence 2026-09-08 19:08:12 -04:00
Swung0x48 b938c83b6f [Fix] (Test): let a repair that did not put the tree back reach the descriptor control's exit code, from the loop and from the EXIT trap 2026-09-08 19:08:12 -04:00
Swung0x48 e62abff6b6 [Fix] (Magma): drop the per-kind ABA wrapper no build ever evaluates and say why the constexpr table and the knob stand alone 2026-09-08 19:08:12 -04:00
swung0x48 9a0a7f7608 [Fix] (Test): stop G7's descriptor control carrying on past a Ctrl-C - latch the interrupt from the trap and from a signalled child, and report it as an interruption rather than as the contract tree's verdict 2026-09-08 19:08:12 -04:00
swung0x48 993ce0fb76 [Fix, CI] (Pipe): order the byte-identity listing, prove every protected region reaches its closing brace, and pin the include-closure probe count 2026-09-08 19:08:12 -04:00
swung0x48 ea38cfcb99 [Fix, Test] (Pipe): read the shader composite band's own counters in the composite leak case, arm the ABA flip from anywhere under a backend, match Espryt's exact refusal line and pin the bit-10-requires-bit-11 arm 2026-09-08 19:08:12 -04:00
swung0x48 9c3e8ffd32 [Fix] (Test): make G7's descriptor negative control able to report success - a patch that compiles into a scoped enum, a verdict its own patcher cannot pollute, and repair state the signal traps can see 2026-09-08 19:08:12 -04:00
swung0x48 2f35014d73 [CI] (Pipe): gate that the unpack ring, the attachment permutation, the depth-stencil sampling core and the format caveat did not move 2026-09-08 19:08:12 -04:00
swung0x48 8c8d460e14 [Test] (Pipe): switch P4a's four object subsystems off against the shipping mask and record the texture upload shape the two counters make visible 2026-09-08 19:08:12 -04:00
swung0x48 b6e5c2bb39 [Test] (Espryt): assert a glTexParameter on a texture that only ever was an attachment, an image binding or a copy endpoint reaches the driver 2026-09-08 19:08:12 -04:00
swung0x48 246434bb35 [Test] (Pipe): reproduce the texture, framebuffer, renderbuffer, sampler, view and program handle ABA through public GL and prove the pre-rekey guards are what stops it 2026-09-08 19:08:12 -04:00
rereview 41b7905000 [Fix] (Espryt, Pipe): carry the bound sampler state's CSO handle into the sampler twin's sync and mix the texture-bind generation into the shader-image shutter - the twin resolved a content-addressed record by an identity handle, and an image re-bind that only moved the level never re-emitted the set 2026-09-08 18:46:58 -04:00
rereview 2e03b82a67 [Fix] (Espryt): scope the two sampler-window refusals to a draw that actually touches a texture unit, because an empty window is the correct emission for one that touches none 2026-09-08 18:44:25 -04:00
rereview 2bebbbdcb7 [Fix] (Espryt): flip every remaining seam-shaped decline site loud now that the client half has landed, and record against each mask-shaped one that it was left silent deliberately 2026-09-08 18:44:25 -04:00
rereview 500a8e462a [Fix] (Espryt): resolve the framebuffer twin by handle, ask the applier's bound handles whether one framebuffer is bound to both bindings, and make an undescribed binding say so instead of declining in silence 2026-09-08 18:44:25 -04:00
rereview be939e2cfe [Refactor] (Espryt): call package D's four family wrappers instead of this package's temporary latches, and take wire v3's framebuffer accessors in their landed pointer form 2026-09-08 18:44:25 -04:00
swung0x48 68d09bb585 [Refactor] (Espryt): index every decline site with the flip it takes at the verification round, and invalidate rather than stamp the forced framebuffer bind 2026-09-08 18:44:25 -04:00
swung0x48 5e7fb18c4a [Fix] (Espryt): make a half-described framebuffer applier and a mis-keyed program record say the seam line, and refuse a short global-constant block instead of falling back to the frontend block 2026-09-08 18:44:25 -04:00
swung0x48 74d2daea1b [Fix] (Espryt): unbind the sampler on every touched unit the pushed window does not describe instead of leaving an earlier draw's object on it 2026-09-08 18:44:25 -04:00
swung0x48 7afa2f18d3 [Fix] (Espryt): sweep every image unit the driver holds rather than only the pushed window, and say the seam line when a record names another texture at the validate point 2026-09-08 18:44:25 -04:00
swung0x48 64332060fd [Refactor] (Espryt): read the applier's framebuffer records through one accessor, carry D-K2's fourth row into the temporary latches and tick the declining epoch arm as a miss 2026-09-08 18:44:25 -04:00
swung0x48 96dca5a8ba [Refactor] (Espryt): name every emulation that cannot survive a split and take the format decisions from the descriptor 2026-09-08 18:44:25 -04:00
swung0x48 e24e30bbec [Refactor] (Espryt): bind samplers and the program's resources from the applier's resolved sets and keep the raw-depth-fetch substitution on the server 2026-09-08 18:44:25 -04:00
swung0x48 929d162594 [Refactor] (Espryt): bind shader images from the pushed set and leave the format recast and the split view where they are 2026-09-08 18:44:25 -04:00
swung0x48 0b12f89f3b [Refactor] (Espryt): drive the per-draw texture sync from the pushed sampler-view set and give a read-only attachment its texture parameters 2026-09-08 18:44:25 -04:00
swung0x48 31e6a7d6d8 [Refactor] (Espryt): resolve the current framebuffers from the pushed records and keep the shadowed default bind and the missing fast path exactly as they are 2026-09-08 18:44:25 -04:00
rereview 29bd4d2c94 [Fix, Test] (MG_Impl, Pipe): gate the four P4a families on D-K2's dependency bits as well - Espryt refuses bit 10 without bit 11 or bit 7, but the client had already emitted and the acceptance-cleared level flags left its legacy arm nothing to upload 2026-09-08 18:35:01 -04:00
rereview 588b2277c5 [Fix] (Espryt): stop calling a texture that was never glTexParameter-ed a malformed record - ParamsSerial 0 means no set_texture_params was ever applied, so there is no built-in sampler to push 2026-09-08 18:14:22 -04:00
rereview 1e8cdc5931 [Fix] (Espryt): take the sampler CSO record from the handle the caller carries - a content-addressed record can never be named by the twins own identity handle, so every bound sampler objects parameters were refused on the integrated tree 2026-09-08 18:14:22 -04:00
rereview a632e26e47 [Fix] (Espryt): close the nine re-review minors - the framebuffer refusal now claims only what it can, the adoption refusals have a reachable caller, the carried region offsets and the sub-data resource byte are cross-checked, the remint-pull marker fires only when a level is owed, and a record-empty point the frontend still holds is refused 2026-09-08 18:14:22 -04:00
rereview ee31944bce [Fix, Test] (Pipe): spell the skip twin's line continuation as a real newline - the previous commit left a literal backslash-n and the pull build of the suite did not compile 2026-09-08 17:47:01 -04:00
rereview 245daca06a [Test] (Pipe): give the no-consumer texture gate case its pull-build skip twin so the pull and push test-name sets stay equal 2026-09-08 17:46:05 -04:00
rereview 29d51ab98b [Fix] (MG_Pipe, MG_Impl): gate the four P4a families on a backend having registered MGPipeResourceOps - Magma emitted, the applier accepted, and the acceptance-cleared dirty flags left its legacy path nothing to upload 2026-09-08 17:37:37 -04:00
swung0x48 17396216e0 [Fix] (Espryt): drop three #undefs for macros that were never defined and correct the attachment-version note that still called the record-driven walk another package's work 2026-09-08 16:57:36 -04:00
swung0x48 a666201708 [Test] (Espryt): assert an attachment-only texture's parameters reach the driver with no sampler view, prove every P4a kind's slot returns at a moved generation, and stop one refusal message claiming what it cannot tell 2026-09-08 16:57:36 -04:00
swung0x48 71a174a531 [Fix] (Espryt): consume the carried region offset, refuse a record whose regions disagree about the level pitch, stop reading the frontend border form on the handle arm, and drop a nested guard that repeats its parent 2026-09-08 16:57:36 -04:00
swung0x48 83a209bbd4 [Refactor] (Espryt): resolve framebuffer attachments and the four cross-object masks from the record's surfaces, adopting each twin by the handle the record carries, and drop the registry release helper nothing calls 2026-09-08 16:57:36 -04:00
swung0x48 832620ddeb [Fix] (Espryt): allocate texture storage from the pushed descriptor, honour a metadata-only respecify's sticky mask, and arm the server's own re-dirty in the applier's pending set instead of a model the handle arm never reads 2026-09-08 16:57:36 -04:00
swung0x48 25e1a8483b [Fix] (Espryt): decode the sub-data target's upload half through the contract's accessor, read the depth-stencil aspect and surface-kind constants c0c minted, and refuse bit 10 without bit 11 2026-09-08 16:57:36 -04:00
swung0x48 2bd8f3c82d [Fix] (Espryt): resolve the framebuffer record by the handle it names and refuse before binding, so a DSA blit or clear configures the framebuffer it is about to write instead of one that never got its attachments 2026-09-08 16:57:36 -04:00
swung0x48 e021b4f62b [Test] (Espryt): drive the sampler-view table through the handle arm and prove every P4a death notice is idempotent 2026-09-08 16:57:36 -04:00
swung0x48 a3bd7f0d7d [Refactor] (Espryt): take the sampler parameters and the program artefacts from the applier instead of the frontend objects 2026-09-08 16:57:36 -04:00
swung0x48 5046b3fc89 [Refactor] (Espryt): sync the driver framebuffer from the pushed record and answer the read buffer from the resolved read surface 2026-09-08 16:57:36 -04:00
swung0x48 5e5dc36e57 [Refactor] (Espryt): allocate renderbuffer storage from the pushed descriptor and keep the deferred out-of-memory report where it is 2026-09-08 16:57:36 -04:00
swung0x48 2bd19d5ee2 [Refactor] (Espryt): drive texture storage, parameters and uploads from the pushed descriptors and take the upload strides from the record 2026-09-08 16:57:36 -04:00
swung0x48 33f5e878e7 [Refactor] (Espryt): key the texture, renderbuffer, framebuffer, sampler and program twins on the client-minted handle instead of the frontend object 2026-09-08 16:57:36 -04:00
rereview 568d587ae7 [Fix, Test] (Pipe): reconcile the framebuffer and texture emit suites after the client replay - the union of wire's and the client's cases in one file, spelled against the contract's Named enumerator 2026-09-08 16:54:50 -04:00
swung0x48 602326f379 [Docs] (Pipe): restate what the records-reach-the-applier predicate is for now that the constant is set - the A/B arm it keeps compilable, and the two places a discarded call may not be treated as an accepted one 2026-09-08 16:52:11 -04:00
swung0x48 0338812cf3 [Test] (Pipe): pin the region tail against what the applier stored, the dirty flag surviving a refusal, the metadata respecify an immutable texture needs, the per-object Named record and the six minors 2026-09-08 16:52:11 -04:00
swung0x48 09e0a83cee [Fix] (GLImpl, Pipe): republish a texture parameters when a write lands on its built-in sampler and give every DSA entry point that hands a framebuffer over by name a record for it 2026-09-08 16:52:11 -04:00
swung0x48 771e8e06a1 [Fix] (Pipe, State): take the texture and framebuffer families through the contract own birth hooks, wire the texture subsystem bit, hand the applier the region tail it was promised and clear a level dirty flag only where the record was accepted 2026-09-08 16:52:11 -04:00
swung0x48 2d2090cf1c [Feat, Test] (Pipe): wire the framebuffer subsystem bit and pin the resolved read surface, the sticky bind mask, the level-shadow strides and the union-box/region-list invariant 2026-09-08 16:52:11 -04:00
swung0x48 a11a5eb2af [Feat] (Pipe): push the bound framebuffers with a resolved read surface, inline attachment formats and a content hash that covers the draw-buffer array 2026-09-08 16:52:10 -04:00
swung0x48 612dbac44f [Feat] (Pipe, State): mint a {slot, gen} handle for every texture and renderbuffer and publish its create, respecify, parameters and accumulated sub-data as pipe calls 2026-09-08 16:52:10 -04:00
swung0x48 92dffb81b4 [Test] (Pipe): drive the sampler cache's two unaccountable-release counters - a handle it never handed out and a second release of a reference only one holder owed 2026-09-08 15:57:24 -04:00
swung0x48 79b581984b [Fix, Test] (Pipe): read a forked refusal drive's log from the offset it had instead of unlinking the file - once a case initialises the library the log is already open, so the child was writing into a deleted inode and the parent read nothing 2026-09-08 15:41:13 -04:00
swung0x48 d6e52f75c3 [Fix] (Pipe): count the sampler cache's unaccountable releases and its referenced evictions rather than asserting them, and state what a shared reference count, an over-capacity cache and the per-unit reconciliation really cost 2026-09-08 15:33:42 -04:00
swung0x48 ac09e5b37a [Fix] (Pipe): key the composite resolver's memo on the context as well as the pipeline's GL name - the resolver is a process singleton while GL names are per context, so a make-current released the other context's live composite 2026-09-08 15:33:42 -04:00
swung0x48 820d1a60b9 [Fix] (Pipe): stop a make-current disarming the composite resolver's release path - the memo's freshness and the slot's release obligation were one flag, so after the first Reset no signature move ever spoke a delete 2026-09-08 15:24:22 -04:00
swung0x48 f0f8cab682 [Fix] (Pipe): give the program family its birth-hook entry point and publication latch, invalidate the global-constants key whenever a create_shader_state is re-issued, and count a truncated module tail instead of asserting it 2026-09-08 15:24:22 -04:00
swung0x48 b2edca2728 [Fix] (Pipe): give the sampler family the birth-hook entry points the contract declares, take the publication latch where its creates go out, and reference-count cache entries so an LRU eviction can never take a handle a standing record still names 2026-09-08 15:24:22 -04:00
swung0x48 ccde29064a [Test] (Pipe): wire the sampler and program subsystems and pin the padding-proof CSO identity, the program-resolved unit sets, the linked-snapshot stage mask and the composite band's single release 2026-09-08 15:24:22 -04:00
swung0x48 bc2aad2176 [Feat] (Pipe): resolve a program pipeline into one shader CSO out of the reserved composite band and release it exactly once 2026-09-08 15:20:36 -04:00
swung0x48 5a3e9f0857 [Feat] (Pipe): publish a program's per-stage SPIR-V and reflection archive as a shader CSO and its default uniform block as global constants 2026-09-08 15:20:36 -04:00
swung0x48 1534cf3784 [Feat] (Pipe): content-address sampler states, mint one sampler view per texture and push the three unit sets behind their own content hashes 2026-09-08 15:20:36 -04:00
swung0x48 712c946744 [Fix] (Pipe): retire wire's local Named constant now that the contract mints MGPipeFramebufferTarget::Named 2026-09-08 15:08:22 -04:00
swung0x48 8f1eaafa79 [Test] (Pipe): pin what a Named framebuffer record does and does not move, the stale-generation refusal on the per-object table, the two handles it will not store, and that a respecify which redefines no storage carries the mask without eating the texels 2026-09-08 15:07:23 -04:00
swung0x48 08922b6f78 [Fix] (Pipe): read a sub-data record's target halves through the contract's own accessors, answer the emitter whether a create or a respecify was accepted, and apply a respecify that restates the stored storage as a metadata update rather than a redefinition 2026-09-08 15:07:23 -04:00
swung0x48 711d5c61ba [Feat] (Pipe): key the framebuffer record by the framebuffer handle and resolve the two bindings through it, so a framebuffer that is named but bound to neither can still be described 2026-09-08 15:07:23 -04:00
swung0x48 5ab90dec24 [Test] (Pipe): pin a respecify's per-level scope, the sub-data acceptance signal, the upload targets that name no texture and what a release of the object records clears 2026-09-08 15:07:23 -04:00
swung0x48 4299acd9c1 [Docs] (Pipe): correct the refusal counter's family list - set_framebuffer_state resolves no record and can only fault - and warn that a texture's built-in sampler handle may outlive its CSO 2026-09-08 15:07:23 -04:00
swung0x48 953d73f111 [Fix] (Pipe): give unmap_persistent's kind check the verdict resource_destroy's has, and say why the other three buffer-only calls have nothing to check 2026-09-08 15:07:23 -04:00
swung0x48 d01439d846 [Fix] (Pipe): release the framebuffer records and the three unit windows with the object records whose handles they hold 2026-09-08 15:07:23 -04:00
swung0x48 c9958075e8 [Fix] (Pipe): scope a respecify's pending-upload clear to the level it redefines, answer the emitter whether a sub-data record was accepted, and refuse a resource target that names no texture 2026-09-08 15:07:23 -04:00
swung0x48 ae1a1c503f [Test] (Pipe): pin every P4a record's lifecycle, its bounds gate and what a make-current does and does not clear 2026-09-08 15:07:23 -04:00
swung0x48 a02f1571f5 [Feat] (Pipe): apply the shader CSO's artefacts and the default uniform block without ever re-linking on the server 2026-09-08 15:07:23 -04:00
swung0x48 dca3eb868e [Feat] (Pipe): apply sampler states, sampler views and the three unit sets into the server's own working state 2026-09-08 15:07:23 -04:00
swung0x48 7408bad9fd [Feat] (Pipe): apply the framebuffer record per bound target and the texture and renderbuffer resource calls into their own slot-indexed records 2026-09-08 15:07:23 -04:00
swung0x48 e8502a6100 [Fix] (Pipe): key the framebuffer record to the object it names - a fourth Target value for every DSA entry point that hands a framebuffer over by name, and the metadata-only respecify the sticky bind mask needs 2026-09-08 14:50:30 -04:00
swung0x48 9ea44389e7 [Fix, Test] (Pipe): widen the two shutters that cannot see their own subject rather than gate their emitters on a second bit - glBindSampler moves only bit 12's generation, and GetCurrentProgram() is null for the whole life of a bound separable pipeline, so bind_sampler_states and the entire program family under-fired 2026-09-08 14:30:03 -04:00
swung0x48 17db759891 [Fix] (Pipe): state the four seam encodings the packages were each inventing - the sub-data target packing, the depth-stencil aspect numbers, the surface kind constants and the texture target the surface record grew where its padding was 2026-09-08 13:51:39 -04:00
swung0x48 2cb44039b5 [Fix] (Pipe): stop the include-closure gate going green on a compiler it could not find or on a probe count nobody asked for 2026-09-08 13:13:21 -04:00
swung0x48 32033d6993 [Fix] (Pipe): land the birth half of the P4a client API behind a publication latch both halves read, gate every emitter on its family's wired constant, raise the sampler view's death notice and count the shader composite band apart from the ordinary slots 2026-09-08 13:13:21 -04:00
swung0x48 08192d7266 [Feat] (Pipe): land the P4a contract - the resource target enum, the framebuffer target byte, the texture params' builtin sampler, the sampler-parameter field table, four subsystem bits, seven dirty arms and the program archive codec 2026-09-08 12:18:29 -04:00
swung0x48 37da3c3a07 [Docs] (Disaggregated): record the P3a landing - the five-part gate on the finished tree, the three seam defects the real-path round found, the Track H census, the two-device three-arm A/B on Release APKs (pull / P2-only 0x7f / P3a) with the P2 -O0 erratum, DriverBench T1/T2 against the 0x7f arm, the exit-order UAF closure, and the rulings (ID-13/15/17) 2026-09-08 10:23:59 -04:00
swung0x48 fde5fda3b5 [Fix] (MG_Impl): never destroy the four remaining MGPipe singletons - the vertex-input emitter is on ~VertexArrayObject own death path since C-1 and answered RecordIsPublished out of a freed latch vector 2026-09-08 08:20:55 -04:00
swung0x48 6515c8e6ae [Fix] (MG_Backend, MG_Impl): give the MGPipe input blocks leak-at-exit storage - gPipeInputs and the two verify blocks held the last SharedPtr to a deleted-but-bound vertex array, so __run_exit_handlers ran ~VertexArrayObject and ~BufferObject into a pipe and a backend that were already torn down 2026-09-08 08:20:55 -04:00
Swung0x48 d54ec57a5d [Fix] (MG_Impl, MG_Pipe): outlive the exit handlers - a frontend object destroyed by __run_exit_handlers reached the slot allocator, the resource tracker and the applier after their own destructors had run 2026-09-08 07:33:16 -04:00
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
swung0x48 4826806881 [CI] (Workflows): run the test and apk lanes on every push to feat/disaggregated
- the MGPipe phases land as a series of pushes and each one needs the full lane; dispatching
  by hand after every merge is a step that gets forgotten
- both entries carry a remove-before-merging-to-dev note: dev's trigger set is what ships
2026-09-06 06:32:37 -04:00
159 changed files with 57684 additions and 495 deletions
+4
View File
@@ -6,6 +6,10 @@ on:
- dev
- Feat/Backend-Direct-GLES
- Feat/Backend-Direct-Vulkan
# TEMPORARY, remove before merging the MGPipe work into dev: the disaggregation
# branch runs the full lane on every push so a phase's landing is not gated on
# someone remembering to dispatch the workflow by hand.
- feat/disaggregated
workflow_dispatch:
jobs:
+217 -13
View File
@@ -6,6 +6,10 @@ on:
- dev
- Feat/Backend-Direct-GLES
- Feat/Backend-Direct-Vulkan
# TEMPORARY, remove before merging the MGPipe work into dev: the disaggregation
# branch runs the full lane on every push so a phase's landing is not gated on
# someone remembering to dispatch the workflow by hand.
- feat/disaggregated
workflow_dispatch:
inputs:
baseline_sha:
@@ -416,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()
@@ -538,6 +549,91 @@ 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).
# P4a ADDS THREE ALTERNATIVES, and each one is here because this is the only CI job that
# unpacks a push build:
# * `ObjectSubsystem` reaches the three DirectGLES.ObjectSubsystemControl. entries - the
# 0x1fff-vs-0x1ff A/B and the 0x9ff dependency refusal (G12). `ResourceSubsystem` does
# NOT match it: the two families are named apart on purpose, because they are different
# phases' switches and a filter that merged them would hide one behind the other.
# * `TextureParamsWithoutASamplerView` reaches G9's four cases, the scenario ROADMAP.md:20
# names by hand. It runs in the ambient lanes, which the label already selects - but this
# step is where those cases run against a PUSH library, and G9's whole claim is about the
# push path. All four cases are green on the contract commit - including the one D-E3
# expected to be red, for the reason the scenario's header records - so this row is green
# from the day it lands and goes red only if a reachability path stops syncing texture
# parameters at all, which is the coupling ARCHITECTURE.md:100 exists to remove.
# * `TextureUploadShape` is RECORDED, not gated (D-D4): it asserts that the two upload-shape
# counters could be read and that they agree, and prints the shape for MEASUREMENTS.md. It
# is in the filter so that the number is actually collected on every run - an unmeasured
# shape is not a recorded one - and because its own assertions can go red.
# As with the four before them, each alternative is the SHORTEST string that selects only what
# it means to.
- name: The handle-ABA, CSO, subsystem and texture-parameter controls (G8, G8b, G9, 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|ObjectSubsystem|TextureParamsWithoutASamplerView|TextureUploadShape' \
--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
@@ -694,7 +790,16 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y clang-20 libx11-dev
- name: Include-closure assertions and negative control
run: python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all
# --expect-probes 4 (contract-v2.md 7.6): an exit code cannot tell four probes from none,
# so a --probe typo or a manifest edit that selected nothing would run zero probes and
# exit 0 - the second half of the finding that added the flag. The count is the length of
# scripts/check_include_closure.py's PROBES list and changing one means changing the other.
#
# --compiler stays clang++-20, which is what the step above installs (Debian's clang-20
# package ships /usr/bin/clang++-20). It is deliberately NOT the bare `clang++` the local
# campaign gate spells: that spelling exists because the WSL box has no clang++-20, and
# copying it here would trade a version-pinned compiler for whatever the runner has.
run: python3 scripts/check_include_closure.py --mode both --compiler clang++-20 --self-test --require-all --expect-probes 4
benchmark:
runs-on: ubuntu-latest
@@ -1479,10 +1584,27 @@ 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:
# THE CURRENT PHASE's base ref, for the two G5 region gates below. It is 37da3c3a - P4a's
# base ref, INTEGRATOR-DECISIONS ID-1 - 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 these
# gates ask "did the do-not-touch list move since the phase started".
#
# IT MOVED FROM P3a's 44c2b5cf TO P4a's 37da3c3a WITH THE PHASE, and that is a deliberate
# narrowing rather than a loss: P3a's eleven functions were compared against 44c2b5cf at P3a's
# own exit and were byte-identical there, so 37da3c3a carries the same bodies (measured: the
# eleven shas at 37da3c3a are the eleven shas at 44c2b5cf, and FlushPendingRangesFrom's is
# still the sha pinned in the script at 3e298c9a). What the two gates now both answer is "did
# anything on the list move during P4a", which is the question this phase can act on.
BASELINE: "37da3c3a"
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
@@ -1521,11 +1643,93 @@ 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
# A GATE AS OF P4a (G5), and a SECOND script rather than an edit to the one above. P4a extends
# the same claim to the rest of ARCHITECTURE.md:318's do-not-touch list - the unpack PBO ring's
# staging repack and its two helpers, the attachment permutation, the D24S8 sampling-emulation
# core and the format-caveat handler - which is SEVENTEEN regions across THREE files
# (BRIEF-P4A.md D-N: P3a's eleven, which P4a must not touch either, plus P4a's six). The
# parent's SOURCE_PATH is a single file, so the extension needed a per-region source path and
# a region KIND (DepthStencilSamplingReadImpl is a namespace, not a function); everything else
# about the extraction is its parent's, verbatim.
#
# Both scripts run. The parent keeps answering its own question against its own eleven, so a
# regression in either half names itself, and neither gate can be silenced by editing the
# other's list.
#
# Same feat/disaggregated-or-dispatch guard as the P3a step, for the same reason: the question
# is "did these move since the phase started", and on dev - where unrelated buffer and texture
# fixes land on their own schedule - it would be 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 is not optional: a comparison that silently
# stopped comparing produces exactly the same green as seventeen untouched regions. It runs
# three positive controls (seventeen regions extracted, an untouched copy compared equal, an
# edit OUTSIDE them invisible in all three files) and FOUR negative ones - ClearBufferPool,
# FlushPendingRangesNow, RecomputeBackendColorSlots and StageBlocksIntoUnpackRing, each
# perturbed on its own and each required to be named BY NAME - and fails if any of them does
# not answer.
- name: The unpack ring, the attachment permutation, the D24S8 core and the format caveat did not move (G5)
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
run: bash scripts/p4a_untouched_regions.sh "${BASELINE}" HEAD
- name: The P4a untouched-region gate can still fail (G5)
if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }}
run: bash scripts/p4a_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
+31
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,31 @@ 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
# P4a's contract: the reflection-archive serializer over ProgramArtifacts.h's
# VisitFields tables. Push-only for the same G1 reason as the three above - in
# monolith the archive never crosses (create_shader_state hands the two structs over
# by pointer beside the record), so the codec is live code only in the VERIFY lane,
# where the applier serialises, deserialises and field-compares before storing.
MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp
)
endif()
@@ -551,6 +579,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}")
+41 -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,34 @@ 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::kMGPipeSubsystemsMigratedAtP4a), so MOBILEGL_PIPE_PUSH=0 in the
// environment is the all-pull control and 0x1ff (kMGPipeSubsystemsMigratedAtP3a) is
// the "everything before P4a" control P4a's A/B is run against - each phase's
// constant survives as the next phase's control, which is why none of them is ever
// edited. 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)
// 0x200 framebuffer (set_framebuffer_state) - requires 0x400
// 0x400 texture resources (texture + renderbuffer resource_*,
// set_texture_params) - requires 0x80 AND 0x800
// (the built-in sampler CSO a set_texture_params record names is minted by
// the sampler family alone, ID-15; the four rows are MG_Impl/Pipe/PipeFill.cpp's
// kMGPipeP4aFamilyDependencies, mirrored bit for bit by Espryt's resolvers)
// 0x800 samplers (sampler CSO, sampler view, set_sampler_views /
// bind_sampler_states / set_shader_images) - requires 0x400
// 0x1000 programs (shader CSO, set_draw/dispatch_program, global constants)
// A dependency that is not met is REFUSED with one ERROR naming both bits and the
// family runs its legacy arm; it is never half-run.
// 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 +373,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 kMGPipeSubsystemsMigratedAtP4a, the push build's PipePush default (the P2 and P3a
// constants beside it are the phase-by-phase controls, 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
// kMGPipeSubsystemsMigratedAtP3a (0x1ff) is the phase-by-phase control - P4a's four
// subsystems off, everything P3a landed still on.
features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP4a);
#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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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,603 @@
// 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.
// P4a (BRIEF-P4A.md D-I2, G8): WHICH KINDS THIS ANSWER COVERS, and it is not "all of them".
//
// P4a mints six more client-side kinds - Texture, Renderbuffer, Framebuffer, SamplerCso,
// SamplerViewCso and ShaderCso - and requires the ABA control to defeat "the identity half
// of P4a's memo keys as well", because a control that only defeats the guards a phase
// RETIRED says nothing about the key that phase SHIPS.
//
// On Magma there is no such key to defeat, and that is a fact about the roadmap rather than
// an omission here. MagmaPipeIdentityTables below mints exactly TWO kinds,
// VertexElementsCso and Buffer; a texture, a framebuffer, a sampler, a view and a program
// are all still reached from their frontend objects on this backend, and moving them onto
// handles is P7's work (ROADMAP.md:24 - "Magma anything"; P4a leaves MG_Backend/DirectVulkan
// untouched apart from this file). So the honest statement is per KIND, and it is spelled as
// code rather than as a comment so that a caller cannot read the blanket answer above and
// conclude the knob covers its kind:
//
// * for the two kinds this backend really keys on {slot, gen}, the knob defeats the
// identity exactly as it always has (MagmaPipeClaimSlotMemos);
// * for P4a's six there is nothing here to defeat, so the answer is FALSE - and
// MG_IntegrationTest's HandleRecycleScenario reads that through its own build probe and
// makes those cases' AbaControl arm assert the CORRECT pixels while SAYING that it is
// not controlling anything for that kind. It does not assert a corruption that no code
// on this tree can produce, which would be a permanently red always-on lane.
//
// WHAT MAKES IT TRUE LATER, in one sentence, so the next reader does not have to derive it:
// when a backend grows a Features.PipeHandleAbaControl consumer over its P4a object slot
// tables - one `if` in GetOrCreate / FindByHandle, the shape MagmaPipeClaimSlotMemos already
// has for vertex input - this function's per-kind answer becomes that consumer's, the
// integration probe finds the consumer, and the six cases flip to expecting the corruption.
inline Bool MagmaPipeAbaControlDefeatsIdentity() {
return MG_Config::Features.PipeHandleAbaControl;
}
// WHICH KINDS THIS BACKEND ACTUALLY KEYS ON {slot, gen}, and therefore which kinds the knob
// above has an identity to defeat at all. `kind` is MG_Pipe::MGPipeKind.
//
// EXHAUSTIVE, WITH NO `default:`, for MG_IntegrationTest/Harness/PipeSlotPeek.cpp's reason:
// a kind added to MGPipeKind without a decision here must be a -Wswitch warning in this
// file rather than a row that silently inherits somebody else's answer. Being wrong in the
// "covered" direction is the expensive one - a control asserting a corruption nobody can
// produce is a permanently red always-on lane - so an undecided kind must never read true,
// and with no `default:` there is no arm for it to read true from.
//
// constexpr AND PINNED BY static_assert BELOW, which is what stops it rotting the way a
// predicate with no caller does: MagmaPipeIdentityTables mints exactly two kinds, the
// asserts say so in both directions, and the file no longer compiles if the tables and this
// statement of them ever part company. (Review F-m5: the earlier form had no caller at all
// and could not make anything red or green.)
inline constexpr Bool MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind kind) {
switch (kind) {
// The two MagmaPipeIdentityTables really mints.
case MG_Pipe::MGPipeKind::VertexElementsCso:
case MG_Pipe::MGPipeKind::Buffer:
return true;
// P4a's six object classes: still reached from their frontend objects on this
// backend (Magma's object paths are P7, ROADMAP.md:24), so there is no key here for
// the knob to defeat.
case MG_Pipe::MGPipeKind::Texture:
case MG_Pipe::MGPipeKind::Renderbuffer:
case MG_Pipe::MGPipeKind::Framebuffer:
case MG_Pipe::MGPipeKind::SamplerCso:
case MG_Pipe::MGPipeKind::SamplerViewCso:
case MG_Pipe::MGPipeKind::ShaderCso:
// ...and everything else this backend does not mint a handle for.
case MG_Pipe::MGPipeKind::None:
case MG_Pipe::MGPipeKind::Xfb:
case MG_Pipe::MGPipeKind::RenderStateCso:
case MG_Pipe::MGPipeKind::Fence:
case MG_Pipe::MGPipeKind::Query:
case MG_Pipe::MGPipeKind::Context:
case MG_Pipe::MGPipeKind::KindCount:
return false;
}
return false;
}
static_assert(MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::VertexElementsCso),
"MagmaPipeIdentityTables mints VertexElementsCso: the knob has an identity to "
"defeat for it");
static_assert(MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Buffer),
"MagmaPipeIdentityTables mints Buffer: the knob has an identity to defeat for it");
static_assert(!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Texture) &&
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Renderbuffer) &&
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Framebuffer) &&
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::SamplerCso) &&
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::SamplerViewCso) &&
!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::ShaderCso),
"P4a's six object classes are not keyed on {slot, gen} on this backend, so "
"HandleRecycleScenario's six AbaControl arms must NOT expect a corruption here. "
"Wiring one of them is what flips this assert, this predicate and that arm - and "
"MG_IntegrationTest's two-symbol probe over MG_Backend/DirectVulkan is what "
"carries the answer into the lane");
// THERE IS DELIBERATELY NO PER-KIND WRAPPER HERE, and review F-v2-m3 is why. An earlier
// round carried `MagmaPipeAbaControlCoversKind(kind)` - the conjunction of the two
// statements above - and it had no caller anywhere in the tree: the knob's only two
// consumers (VulkanRenderer.cpp's VAO draw memo and VertexInputStateFactory.cpp's pipeline
// key) each hold ONE kind, VertexElementsCso, by construction, so the kind is not a
// variable at either site. A conjunction no build ever evaluates cannot be pinned the way
// the predicate above is pinned - it is not constexpr, because it reads MG_Config::Features,
// so no static_assert can reach it - which makes it exactly the rot F-m5 was raised about,
// one level up: an `&&` whose operands could be inverted or dropped with nothing to say so.
//
// The two pieces stand alone instead, and each is pinned by something that runs:
// MagmaPipeAbaControlKindIsRekeyedHere is constexpr and asserted in BOTH directions by the
// three static_asserts above, which compile in every Magma build; MagmaPipeAbaControlDefeats
// Identity is the knob, and its two consumers are what make it true or false. A call site
// that ever does hold a variable kind writes the `&&` there, where a build will run it.
// 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);
+23 -1
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;
@@ -681,7 +688,22 @@ namespace MobileGL::MG_Pipe {
// The single global the backends read through MGB_CTX (ARCHITECTURE.md 9.2). An inline
// variable: no .cpp is needed for the definition.
inline PipeInputs gPipeInputs{};
//
// LEAK-AT-EXIT STORAGE, and it is the same rule Init.cpp and GlobalObjects.cpp state for
// pGLContext and pActiveBackendObject: "a process that exits without eglTerminate simply
// leaks the global singletons to the OS instead of running destructors during static
// teardown". This block breaks that rule if it is a value, because its O-class members
// are SharedPtrs to FRONTEND objects: a VertexArrayObject that the application deleted
// while it was bound has its last reference here, and destroying this block from
// __run_exit_handlers therefore runs ~VertexArrayObject -> ~BufferObject at exit. Those
// destructors are not exit-safe and cannot be made so - they reach the client's slot
// allocator, the resource tracker, the vertex-input emitter, the applier AND, through
// MGPipeApplyResourceDestroy, the backend's own twin tables, deferred-release queue,
// buffer pool and driver entry points, every one of which is either already destroyed or
// about to be. So the reference is never dropped: nothing here can start such a chain.
// A live context releases these SharedPtrs the ordinary way, at the fill point.
// (P3a; the exit-time heap corruption this closes is p3a-results/exit-order-v1.md.)
inline PipeInputs& gPipeInputs = *new PipeInputs();
// Every field has storage or is forwarded, and nothing else.
#define MGP_INPUT_COUNT_ONE(Field, Member) +1
+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);
@@ -16,6 +16,14 @@
#include <MG_Impl/GLImpl/Getter/GL_Getter.h>
#include <MG_State/GLState/ErrorState/Error.h>
#include <MG_Impl/Pipe/PipeFill.h>
#if MOBILEGL_PIPE_PUSH
// P4a, ID-19(c). This file is the ONLY place every DSA framebuffer entry point lives, and the
// emitter it reaches is this package's own header rather than a declaration in one of the
// contract's: MG_Pipe/PipeMutation.h is the door MG_State has into the client and carries no
// framebuffer row, and MG_Impl/GLImpl and MG_Impl/Pipe are the same layer (this file already
// includes MG_Impl/Pipe/PipeFill.h for MGP_FILL).
#include <MG_Impl/Pipe/FramebufferEmit.h>
#endif
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/GLToMG/TextureEnumConverter.h>
#include <MG_Util/Converters/MGToMG/TextureEnumConverter.h>
@@ -613,6 +621,35 @@ namespace MobileGL::MG_Impl::GLImpl {
framebufferObject->AttachTexture(attachmentType, textureObject, textureUploadTarget, level, 0, layered);
}
#if MOBILEGL_PIPE_PUSH
// P4a, ID-19(c): ANY FRAMEBUFFER THE SERVER IS ABOUT TO RECEIVE BY NAME HAS A RECORD.
//
// The applier keeps framebuffer records PER OBJECT, keyed by the handle - but before
// ID-19 it held only the two BOUND-target records, and the emitter only ever built them
// at the validate point out of the two bindings. So glClearNamedFramebufferfv(fbo) or
// glBlitNamedFramebuffer(..., fbo, ...) on an fbo bound to NEITHER binding reached a
// backend that minted a fresh driver framebuffer with no attachments, found no record
// for it, declined, and issued the clear against it anyway: GL_INVALID_FRAMEBUFFER_-
// OPERATION and nothing cleared, where the legacy arm cleared correctly.
//
// TWO CLASSES OF SITE call this, and both are "the point at which the object is final
// for this call": the five CONSUMERS (blit and the four clears) publish immediately
// before MGP_FILL, so the record precedes the verb that hands the object over and a
// later bound-target record for the same object still wins; the ten MUTATORS (the DSA
// attachment, draw-buffer and read-buffer setters) publish immediately after the
// frontend mutation, because they have no validate point at all - FillPoints.def has no
// verb for any of them, so there is no MGP_FILL to sit in front of.
//
// A CALL THAT MOVED NOTHING IS FREE: the record's ContentHash is the emitter's own
// suppressor and it is keyed per framebuffer object, so a redundant publish emits zero
// bytes. EmitFramebufferByName picks Draw/Read/Both over Named when the object IS
// bound, so a Named record can never overwrite a bound record's Target underneath the
// binding that resolves through it.
void PipePublishFramebufferByName(const SharedPtr<MG_State::GLState::FramebufferObject>& fbo) {
if (!fbo) return;
MG_Pipe::MGPipeFramebufferEmitterInstance().EmitFramebufferByName(*fbo);
}
#endif
} // namespace
void BlitFramebuffer_Backend(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0,
@@ -631,6 +668,10 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_E_ONCE("glBlitNamedFramebuffer skipped: backend does not implement explicit framebuffer blit.");
return;
}
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(readFramebuffer);
PipePublishFramebufferByName(drawFramebuffer);
#endif
MGP_FILL(BlitNamedFramebuffer);
blitNamedFramebuffer(readFramebuffer, drawFramebuffer, srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1,
dstY1, mask, filter);
@@ -643,6 +684,9 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_E_ONCE("glClearNamedFramebufferfv skipped: backend does not implement explicit framebuffer clear.");
return;
}
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebuffer);
#endif
MGP_FILL(ClearNamedFramebufferfv);
clearNamedFramebufferfv(framebuffer, buffer, drawbuffer, value);
}
@@ -654,6 +698,9 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_E_ONCE("glClearNamedFramebufferfi skipped: backend does not implement explicit framebuffer clear.");
return;
}
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebuffer);
#endif
MGP_FILL(ClearNamedFramebufferfi);
clearNamedFramebufferfi(framebuffer, buffer, drawbuffer, depth, stencil);
}
@@ -665,6 +712,9 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_E_ONCE("glClearNamedFramebufferiv skipped: backend does not implement explicit framebuffer clear.");
return;
}
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebuffer);
#endif
MGP_FILL(ClearNamedFramebufferiv);
clearNamedFramebufferiv(framebuffer, buffer, drawbuffer, value);
}
@@ -676,6 +726,9 @@ namespace MobileGL::MG_Impl::GLImpl {
MGLOG_E_ONCE("glClearNamedFramebufferuiv skipped: backend does not implement explicit framebuffer clear.");
return;
}
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebuffer);
#endif
MGP_FILL(ClearNamedFramebufferuiv);
clearNamedFramebufferuiv(framebuffer, buffer, drawbuffer, value);
}
@@ -1404,6 +1457,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (texture == 0) {
framebufferObject->Detach(attachmentType);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
return;
}
@@ -1430,6 +1486,9 @@ namespace MobileGL::MG_Impl::GLImpl {
}
framebufferObject->AttachTexture(attachmentType, textureObject, textureUploadTarget, level, 0, layered);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
}
void NamedFramebufferTextureWithUploadTarget_State(const char* functionName, GLuint framebuffer, GLenum attachment,
@@ -1453,6 +1512,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (texture == 0) {
framebufferObject->Detach(attachmentType);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
return;
}
@@ -1480,6 +1542,9 @@ namespace MobileGL::MG_Impl::GLImpl {
}
framebufferObject->AttachTexture(attachmentType, textureObject, textureUploadTarget, level);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
}
void NamedFramebufferTexture1D_State(GLuint framebuffer, GLenum attachment, GLenum textarget, GLuint texture,
@@ -1534,6 +1599,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (texture == 0) {
framebufferObject->Detach(attachmentType);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
return;
}
@@ -1636,6 +1704,9 @@ namespace MobileGL::MG_Impl::GLImpl {
framebufferObject->AttachTexture(attachmentType, textureObject, textureUploadTarget, level, layer,
/*layered=*/false);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
}
void FramebufferRenderbuffer_State(GLenum target, GLenum attachment, GLenum renderbuffertarget,
@@ -1705,6 +1776,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (renderbuffer == 0) {
framebufferObject->Detach(attachmentType);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
return;
}
@@ -1714,6 +1788,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!renderbufferObject) return;
framebufferObject->AttachRenderbuffer(attachmentType, renderbufferObject);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
}
void DrawBuffersForFramebuffer_State(const SharedPtr<MG_State::GLState::FramebufferObject>& fbo, Bool isDefaultFBO,
@@ -1913,6 +1990,9 @@ namespace MobileGL::MG_Impl::GLImpl {
: GetNamedFramebufferObject_State(framebuffer, "NamedFramebufferDrawBuffers_State");
if (!framebufferObject) return;
DrawBuffersForFramebuffer_State(framebufferObject, framebuffer == 0, n, bufs, false);
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
}
void NamedFramebufferDrawBuffer_State(GLuint framebuffer, GLenum buf) {
@@ -1927,6 +2007,9 @@ namespace MobileGL::MG_Impl::GLImpl {
const GLenum bufs[] = {buf};
DrawBuffersForFramebuffer_State(framebufferObject, framebuffer == 0, 1, bufs, true);
}
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
}
void NamedFramebufferReadBuffer_State(GLuint framebuffer, GLenum src) {
@@ -1936,6 +2019,9 @@ namespace MobileGL::MG_Impl::GLImpl {
if (!framebufferObject) return;
ReadBufferForFramebuffer_State(framebufferObject, framebuffer == 0, src,
"NamedFramebufferReadBuffer_State");
#if MOBILEGL_PIPE_PUSH
PipePublishFramebufferByName(framebufferObject);
#endif
}
SharedPtr<MG_State::GLState::FramebufferObject> GetFramebufferObjectForNamedClear(GLuint framebuffer,
@@ -31,6 +31,9 @@
#include <MG_Util/Math/FixedPointConversion.h>
#include <MG_State/GLState/TextureState/TextureObjectBuffer.h>
#include <MG_Impl/Pipe/PipeFill.h>
// P4a, ID-18 M2. The ONE door MG_State and MG_Impl have into the client's emitters; the three
// call sites below are declarations only, exactly as the frontend's mutators are.
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL::MG_Impl::GLImpl {
static SharedPtr<MG_State::GLState::ITextureObject> nullTextureObject;
@@ -1337,6 +1340,30 @@ namespace MobileGL::MG_Impl::GLImpl {
std::format("pname {} is not a valid texture parameter.", MG_Util::ConvertGLEnumToString(pname))));
return;
}
#if MOBILEGL_PIPE_PUSH
// P4a, ID-18 M2 - THE THIRTEENTH MGP_NOTE_AGGREGATE(TextureParams) SITE, and the one
// no publisher reached. Nine of the thirteen are TextureObject.cpp's own mutators and
// publish through TextureObjectBase::PipePublishParams; the tenth is
// SetDepthStencilTextureMode; two more move fields MGPTextureParams does not carry. The
// last is SamplerObject::BumpVersion, whose own comment calls it "the one choke point
// every setter reaches" - and MGPTextureParams takes MinLod, MaxLod and LodBias off that
// object, so every glTexParameter that writes GL_TEXTURE_MIN_LOD / MAX_LOD / LOD_BIAS
// landed on state nothing watched and the applier's record kept saying MinLod = 0.
// Wrong pixels, not a lost optimisation.
//
// THE HOOK IS HERE RATHER THAN ON BumpVersion because MG_State/GLState/SamplerState is
// package C's after the tag; C.7 grants this file for exactly this class of path ("the
// grant is one call site per path"), and this switch IS the path - every arm of it
// either writes the built-in SamplerObject or writes a texture field that publishes for
// itself. Placed after the switch, so the error arms above return without emitting.
//
// IT IS ALSO ID-14's RE-EMIT HOOK. C's sampler CSO cache is content-addressed, so the
// handle MGPTextureParams::BuiltinSampler names MOVES WITH THE CONTENT; the emitter
// re-Acquires from the cache and releases the previous handle here. An over-call is
// free: the emitter's version-first skip reads GetTextureParamsVersion() AND
// SamplerObject::GetVersion() and returns without hashing anything when neither moved.
MobileGL::MG_Pipe::MGPipeEmitTextureParams(*textureObject);
#endif
}
void TextureParameterObjectf_State(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject, GLenum pname,
@@ -1415,6 +1442,30 @@ namespace MobileGL::MG_Impl::GLImpl {
std::format("pname {} is not a valid texture parameter.", MG_Util::ConvertGLEnumToString(pname))));
return;
}
#if MOBILEGL_PIPE_PUSH
// P4a, ID-18 M2 - THE THIRTEENTH MGP_NOTE_AGGREGATE(TextureParams) SITE, and the one
// no publisher reached. Nine of the thirteen are TextureObject.cpp's own mutators and
// publish through TextureObjectBase::PipePublishParams; the tenth is
// SetDepthStencilTextureMode; two more move fields MGPTextureParams does not carry. The
// last is SamplerObject::BumpVersion, whose own comment calls it "the one choke point
// every setter reaches" - and MGPTextureParams takes MinLod, MaxLod and LodBias off that
// object, so every glTexParameter that writes GL_TEXTURE_MIN_LOD / MAX_LOD / LOD_BIAS
// landed on state nothing watched and the applier's record kept saying MinLod = 0.
// Wrong pixels, not a lost optimisation.
//
// THE HOOK IS HERE RATHER THAN ON BumpVersion because MG_State/GLState/SamplerState is
// package C's after the tag; C.7 grants this file for exactly this class of path ("the
// grant is one call site per path"), and this switch IS the path - every arm of it
// either writes the built-in SamplerObject or writes a texture field that publishes for
// itself. Placed after the switch, so the error arms above return without emitting.
//
// IT IS ALSO ID-14's RE-EMIT HOOK. C's sampler CSO cache is content-addressed, so the
// handle MGPTextureParams::BuiltinSampler names MOVES WITH THE CONTENT; the emitter
// re-Acquires from the cache and releases the previous handle here. An over-call is
// free: the emitter's version-first skip reads GetTextureParamsVersion() AND
// SamplerObject::GetVersion() and returns without hashing anything when neither moved.
MobileGL::MG_Pipe::MGPipeEmitTextureParams(*textureObject);
#endif
}
void GetTextureParameterObjectiv_State(const SharedPtr<MG_State::GLState::ITextureObject>& textureObject,
@@ -2105,6 +2156,30 @@ namespace MobileGL::MG_Impl::GLImpl {
std::format("pname {} is not a valid texture parameter.", MG_Util::ConvertGLEnumToString(pname))));
return;
}
#if MOBILEGL_PIPE_PUSH
// P4a, ID-18 M2 - THE THIRTEENTH MGP_NOTE_AGGREGATE(TextureParams) SITE, and the one
// no publisher reached. Nine of the thirteen are TextureObject.cpp's own mutators and
// publish through TextureObjectBase::PipePublishParams; the tenth is
// SetDepthStencilTextureMode; two more move fields MGPTextureParams does not carry. The
// last is SamplerObject::BumpVersion, whose own comment calls it "the one choke point
// every setter reaches" - and MGPTextureParams takes MinLod, MaxLod and LodBias off that
// object, so every glTexParameter that writes GL_TEXTURE_MIN_LOD / MAX_LOD / LOD_BIAS
// landed on state nothing watched and the applier's record kept saying MinLod = 0.
// Wrong pixels, not a lost optimisation.
//
// THE HOOK IS HERE RATHER THAN ON BumpVersion because MG_State/GLState/SamplerState is
// package C's after the tag; C.7 grants this file for exactly this class of path ("the
// grant is one call site per path"), and this switch IS the path - every arm of it
// either writes the built-in SamplerObject or writes a texture field that publishes for
// itself. Placed after the switch, so the error arms above return without emitting.
//
// IT IS ALSO ID-14's RE-EMIT HOOK. C's sampler CSO cache is content-addressed, so the
// handle MGPTextureParams::BuiltinSampler names MOVES WITH THE CONTENT; the emitter
// re-Acquires from the cache and releases the previous handle here. An over-call is
// free: the emitter's version-first skip reads GetTextureParamsVersion() AND
// SamplerObject::GetVersion() and returns without hashing anything when neither moved.
MobileGL::MG_Pipe::MGPipeEmitTextureParams(*textureObject);
#endif
}
void TexParameteri_State(GLenum target, GLenum pname, GLint param) {
+270
View File
@@ -0,0 +1,270 @@
// MobileGL - MobileGL/MG_Impl/Pipe/CompositeResolver.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>
// P4a's PROGRAM-PIPELINE COMPOSITE, on the client side.
//
// GLContext::GetProgramForDraw() already flattens a bound pipeline into one hidden composite
// ProgramObject entirely in the frontend - it joins every graphics stage, computes the
// pipeline's draw-program signature, looks it up in the pipeline's own cache and, on a miss,
// attaches each stage's LINKED SNAPSHOT into a fresh ProgramObject and links it. All of that
// is frontend work and none of it moves. What this file adds is the one thing the wire needs:
// the composite gets ONE handle, out of the ShaderCso reserved high band, and
// create_shader_state goes out for it exactly as for an ordinary program. THE SERVER NEVER
// LEARNS IT IS A COMPOSITE and needs no "resolved draw program" hook at all.
//
// WHY A BAND RATHER THAN A FLAG ON THE HANDLE: a flag would have to be carried, honoured and
// masked off by every consumer of a ShaderCso handle, on both sides; a reserved slot range is
// a property of the allocator instead, so "an ordinary program can never be handed a composite
// slot" is true by construction. MGPipeSlotAllocator::Allocate refuses the band outright and
// AllocateComposite is the only door in.
//
// WHAT THIS FILE IS ACTUALLY FOR: the composite's slot has TWO INDEPENDENT RELEASE PATHS and
// either order has to free it exactly once.
// * the pipeline cache drops the composite when the draw-program signature moves. In the
// frontend that overwrite drops the last SharedPtr, so the composite's own destructor
// usually runs first; the resolver still speaks the release, because "usually" is not a
// contract and a client that only reacted to destructors would leak a slot the moment the
// frontend started holding a second reference.
// * the composite ProgramObject's own ~ProgramObject, which is an ordinary program's death
// path and takes the same helper.
// Both go through MGPipeEmitShaderCsoDestroyAndFree, and whichever runs second is a PROVEN
// no-op: MGPipeSlotAllocator::Free refuses a slot that is not live at that generation and
// bumps no generation of its own, so a double release cannot skip a generation either.
//
// THE MEMO's KEY IS (CONTEXT ID, PIPELINE GL NAME) AND THE CONTEXT HALF IS NOT OPTIONAL.
// This resolver is a PROCESS singleton while a pipeline's GL name is per context: GLContext
// owns m_programPipelines AND its own name generator m_programPipelineNames (Core.h), so name
// N names two different ProgramPipelineObjects in two contexts, each with its own composite
// and its own handle. Keyed on the name alone, the first emission after a make-current found
// the OTHER context's entry, matched nothing - two composites are two ProgramObjects with two
// lifetime ids, so the handles differ even when the stage set and the signature are identical
// - and released it: a delete_shader_state and a cleared publication latch for a composite
// whose frontend ProgramObject is alive, its band slot handed back and re-issued at gen + 1,
// and the server rebuilding that program (glslang + SPIR-V + spirv-opt, the very cost the
// signature below exists to avoid) once per context switch.
//
// THE CONTEXT ID IS GLContext::GetTextureContextId() AND NOTHING ELSE - the tree's existing
// never-reused per-context id (TextureState::AllocateContextId; PipeInputs carries it as
// m_textureContextId at seven fill points and the backends' own per-context memos key on it).
// Deliberately NOT the GLContext ADDRESS that MGB_CTX_IDENTITY and MGPipeTracker::m_context
// compare, because Core.h states the reason that id exists at all: a context freed and remade
// lands on the old heap address, which would put this same defect back one context recreation
// later.
//
// WHAT RELEASES A DESTROYED CONTEXT's ENTRIES: nothing in this file, and that is the correct
// answer rather than an omission. Destroying a context drops m_programPipelines, which drops
// each ProgramPipelineObject, which drops the composite it cached; ~ProgramObject then runs
// MGPipeEmitShaderCsoDestroyAndFree - the composite's OWN release path, the second of the two
// above - and the slot goes back exactly once. The entries those composites leave behind can
// never be found again (no future Observe can carry a dead context id) and could not release
// anything if they were (the allocator erases the lifetime-id mapping on Free), so Reset()
// DROPS them instead of releasing them. That is also what bounds the vector; see Reset().
//
// THE SIGNATURE IS ComputeDrawProgramSignature(), the per-graphics-stage {lifetimeId,
// GetLinkVersion()} array - and DELIBERATELY NOT GetBackendStateVersion(), which is what made
// the SSO conformance loop rebuild the composite (glslang + SPIR-V + spirv-opt) on every draw,
// because a glUniform1i to a sampler moves it.
//
// HEADER-ONLY, for the ownership reason Tracker.h states: a new .cpp would need the root
// CMakeLists.txt, which is the contract package's.
//
// IT IS INCLUDED BY ProgramEmit.h AND NOT THE OTHER WAY ROUND, deliberately: the composite is
// a special case of the program family's own emission, so the family header depends on this
// one and this one depends on nothing of the family's. The reverse arrangement would make the
// resolver reachable only from a translation unit that had already decided to use it, i.e.
// dead in the build that matters and live only in the tests.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_State/GLState/ProgramState/ProgramObject.h>
#include <MG_State/GLState/ProgramState/ProgramPipelineObject.h>
namespace MobileGL::MG_Pipe {
// IS THIS PROGRAM A PIPELINE COMPOSITE? A composite is the one ProgramObject in the system
// constructed with external index 0 (Core.cpp's MakeShared<ProgramObject>(0u)), and that is
// not an accident of implementation: it is deliberately not a named program, so it must not
// answer glIsProgram and must not consume a GL name, and glCreateProgram never returns 0.
//
// ASKED THIS WAY RATHER THAN CARRIED ON THE OBJECT because a Bool member on ProgramObject
// would resize the pull build's object and break G1 outright - the phase's admitted-resize
// set is empty - and a hook in Core.cpp would have to be maintained on a path that already
// states the invariant in its own comment.
inline Bool MGPipeProgramIsPipelineComposite(const MG_State::GLState::ProgramObject& program) {
return program.GetExternalIndex() == 0;
}
class MGPipeCompositeResolver {
public:
using ProgramObject = MG_State::GLState::ProgramObject;
using ProgramPipelineObject = MG_State::GLState::ProgramPipelineObject;
using DrawProgramSignature = ProgramPipelineObject::DrawProgramSignature;
struct Counters {
Uint64 Mints = 0; // signatures this resolver has seen minted
Uint64 Reuses = 0; // a signature that had not moved
Uint64 Releases = 0; // signature-move releases, i.e. the pipeline-cache path
// Entries dropped by Reset() because the composite's slot was already gone - the
// shape every entry of a DESTROYED CONTEXT ends in. A dropped entry is not a
// release: nothing is emitted and nothing is freed, the obligation having been
// discharged by the composite's own ~ProgramObject.
Uint64 Sweeps = 0;
};
// Told, at every emission, which composite the frontend handed out for which pipeline.
// Returns the handle the emitter should use, which is always the one already minted off
// the composite's own lifetime id - the resolver never mints a second identity for an
// object that has one.
//
// WHEN THE SIGNATURE MOVES the previous composite's slot is released here, through the
// one death helper and in its fixed order. That is the pipeline-cache release path; the
// composite's own destructor is the other one and the second of the two is the proven
// no-op.
MGPipeHandle Observe(Uint64 contextId, const ProgramPipelineObject& pipeline,
const ProgramObject& composite, MGPipeHandle handle) {
const DrawProgramSignature signature = pipeline.ComputeDrawProgramSignature();
const Uint pipelineName = pipeline.GetExternalIndex();
Entry* entry = Find(contextId, pipelineName);
if (entry != nullptr) {
if (entry->Signature == signature && entry->Handle == handle) {
// THE SAME COMPOSITE. Not merely "the same signature": the handle is minted
// off the composite ProgramObject's own lifetime id, so an identical handle
// IS an identical object and there is nothing to release. Live is
// deliberately NOT touched - it is the release obligation and it is still
// owed for exactly this handle.
++m_counters.Reuses;
return handle;
}
// A MOVED SIGNATURE ON THIS CONTEXT's OWN ENTRY, which is the only thing that
// can reach here now: another context's pipeline of the same name is not found
// above and therefore not released, its obligation staying owed to the context
// that took it.
ReleaseEntry(*entry);
} else {
m_entries.push_back(Entry{});
entry = &m_entries.back();
entry->ContextId = contextId;
entry->PipelineName = pipelineName;
}
entry->Signature = signature;
entry->Handle = handle;
entry->CompositeLifetimeId = composite.GetLifetimeId();
entry->Live = true;
++m_counters.Mints;
return handle;
}
// A make-current, and it RELEASES NOTHING. The entries name composites that belong to
// the frontend objects of the context being left, those objects outlive the switch, and
// releasing them would emit a delete for a live program.
//
// NOR IS ANY MEMO INVALIDATED, and that is what the context key bought. This used to
// clear a per-entry `Fresh` flag beside `Live`, because with a name-only key an entry
// could not say whether it described "my own pipeline before the switch" or "another
// context's pipeline of the same name" - and exactly one of those two properties could
// hold at a time. The key answers the question directly now, so the freshness flag and
// its one reader (a HandleFor() accessor that had no caller anywhere in the tree) are
// both gone rather than left as scaffolding: `Live`, the release obligation, is the
// entry's only state and nothing but ReleaseEntry may clear it.
//
// WHAT IS LEFT TO DO HERE IS RECLAMATION, and this is the one moment the client is told
// that a context boundary was crossed. An entry whose composite slot is no longer live
// has had its obligation discharged elsewhere - by that composite's own ~ProgramObject,
// which is precisely what happened to EVERY entry of a context that has just been
// destroyed - so it is DROPPED rather than released: a release would resolve nothing
// anyway (the allocator erases the lifetime-id mapping on Free) and no reader is left.
// Without this the vector would grow by one per (context, pipeline name) pair the
// process ever used, where the name-only key bounded it by the highest pipeline name;
// with it, it is bounded by the pairs whose composite slot is actually live.
void Reset() {
SizeT kept = 0;
for (SizeT i = 0; i < m_entries.size(); ++i) {
if (!m_entries[i].Live || !MGPipeSlots().IsLive(MGPipeKind::ShaderCso, m_entries[i].Handle)) {
++m_counters.Sweeps;
continue;
}
if (kept != i) m_entries[kept] = m_entries[i];
++kept;
}
m_entries.resize(kept);
}
void ResetCounters() { m_counters = Counters{}; }
// Diagnostics and unit cases only; nothing on the emission path asks. There is no
// HandleFor(name) accessor and there must not be one: the emitter takes the handle from
// the composite ProgramObject it already holds, so a lookup by name would be a second
// authority on an identity the allocator already owns.
SizeT Size() const { return m_entries.size(); }
const Counters& GetCounters() const { return m_counters; }
private:
struct Entry {
// NO FRONTEND SharedPtr, and that is the exit-order rule rather than a style
// choice: a static that held one would put a frontend destructor on an exit
// handler's path into a torn-down pipe. A GL name, a signature of plain integers,
// a handle and a lifetime id are all this needs.
// KEYED ON (CONTEXT ID, GL NAME), and the name half is the GL name because a
// ProgramPipelineObject has no lifetime id - ComputeDrawProgramSignature reads the
// STAGE programs' ids and the pipeline itself carries none. The context half is
// GLContext::GetTextureContextId(); see the file header for why the name alone was
// wrong and why the context ADDRESS would be too.
//
// WITHIN ONE CONTEXT glGenProgramPipelines recycles names, so a deleted-and-
// recreated pipeline can still inherit its predecessor's entry; that is bounded and
// self-correcting rather than a hazard. The first Observe on the new object finds a
// signature and a handle that do not match and releases the old entry, and that
// release resolves NOTHING - the allocator erases the lifetime-id mapping on Free,
// so a stale CompositeLifetimeId emits no delete and frees no slot; all it costs is
// one redundant, idempotent death notice, which is the same shape the composite's
// own second release path already has.
Uint64 ContextId = 0;
Uint PipelineName = 0;
DrawProgramSignature Signature{};
MGPipeHandle Handle = kMGPipeNullHandle;
Uint64 CompositeLifetimeId = 0;
// THE RELEASE OBLIGATION. Set when this entry takes responsibility for a composite's
// slot, cleared ONLY by ReleaseEntry when that responsibility is discharged.
Bool Live = false;
};
// BOTH HALVES OF THE KEY, always. An entry of another context is not this pipeline's
// entry: not found, not matched, not released.
Entry* Find(Uint64 contextId, Uint pipelineName) {
for (Entry& entry : m_entries) {
if (entry.ContextId == contextId && entry.PipelineName == pipelineName) return &entry;
}
return nullptr;
}
void ReleaseEntry(Entry& entry) {
if (!entry.Live || entry.CompositeLifetimeId == 0) return;
entry.Live = false;
MGPipeEmitShaderCsoDestroyAndFree(entry.CompositeLifetimeId);
entry.Handle = kMGPipeNullHandle;
entry.CompositeLifetimeId = 0;
++m_counters.Releases;
}
Vector<Entry> m_entries;
Counters m_counters;
};
inline MGPipeCompositeResolver& MGPipeCompositeResolverInstance() {
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason, and named in the phase's risk
// list beside the other three new client singletons: heap-constructed and intentionally
// leaked at exit, holding no frontend SharedPtr.
static MGPipeCompositeResolver* resolver = new MGPipeCompositeResolver();
return *resolver;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+205
View File
@@ -0,0 +1,205 @@
// 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() {
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the
// rule covers every MGPipe process singleton, not only the ones on today's death
// paths.
static MGPipeCsoCache* cache = new MGPipeCsoCache();
return *cache;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+659
View File
@@ -0,0 +1,659 @@
// MobileGL - MobileGL/MG_Impl/Pipe/FramebufferEmit.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 P4a's framebuffer family: set_framebuffer_state, emitted at the validate
// point once per bound TARGET that moved, or once with Target = Both when the two bindings
// name the same object.
//
// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT, and the
// split is the whole reason it exists this early. MG_Impl/Pipe/PipeFill.cpp is the contract
// package's for the entire phase - it carries Coverage.def's enum-coupled block, the validate
// point and the death helpers - so the emitter package must not edit it. What it edits instead
// is this header: the emitter's BODY, and the value of kMGPipeWiredFramebufferSubsystem below.
// That is what makes "no file is touched twice by two packages" structural rather than a
// convention, and it is what the bb2a236d semantic-merge trap taught (two branches green
// separately, the integrated tree not compiling).
//
// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state: the root
// CMakeLists.txt that would name a new .cpp is the contract package's and is frozen behind the
// tag. MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/TextureEmit.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 <algorithm>
namespace MobileGL::MG_Pipe {
// WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the four per-family
// constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives the
// emitters their bodies, with no file touched twice - and a Coverage.def row can never
// silently drop a field on the floor before the call that carries it exists.
//
// TURNING IT ON RETIRES NO PULL. GetFramebufferBindingSlot is the family's one
// Coverage.def emitted row and PipeFill.cpp's EmittedCallSuppliesTheWholeField answers
// FALSE for it, with the reason: the field's storage is a BindingSlot<FramebufferObject> -
// a frontend heap reference - and the call that supplies it carries eight-byte {slot, gen}
// handles and a fully resolved descriptor. So this bit switches the EMISSION on and the
// residual fill keeps writing the mirror, which is what keeps the verify lane at zero
// divergence.
inline constexpr Uint64 kMGPipeWiredFramebufferSubsystem = kMGPipeSubsystemFramebuffer;
inline Bool MGPipeFramebufferSubsystemEnabled() {
return (kMGPipeWiredFramebufferSubsystem & kMGPipeSubsystemFramebuffer) != 0 &&
(MG_Config::Features.PipePush & kMGPipeSubsystemFramebuffer) != 0;
}
// ---------------------------------------------------------------------------------
// D-C1: the MGPSurface builder, one pure function, one statement per field
// ---------------------------------------------------------------------------------
// MGPSurface::Kind's three constants ARE THE CONTRACT'S (ID-12 DV-4, c0c):
// kMGPipeSurfaceKindNone / ...Texture / ...Renderbuffer live in MG_Pipe/MGPipeTypes.h under
// exactly these names with the same MGPipeKind derivation and the same static_assert. This
// package's copies were a redefinition in the same namespace and are deleted.
// The upload target an attachment names, RESOLVED: an attachment made through an entry
// point that carries no face token stores TextureUploadTarget::Unknown, and the record goes
// out fully resolved - nothing in it may require a lookup on the far side.
//
// THE FALLBACK IS ONLY LEGAL FOR A SINGLE-TARGET TEXTURE (m1), and v1's was not. The
// precedent it copied - FramebufferAttachmentObject::GetSize - needs an EXTENT, which is
// identical across a cube map's six faces; face IDENTITY is not, so
// `glFramebufferTexture(GL_COLOR_ATTACHMENT0, cube, 0)` resolved to targets[0] and the
// record ASSERTED CubeMapPositiveX for a layered attachment that names all six. A texture
// with exactly one upload target has a [0] that IS the truth; anything else keeps Unknown,
// which is the value the field already carries for "this attachment names no single face"
// and which Layered = 1 tells the reader to ignore.
inline MobileGL::TextureUploadTarget MGPipeResolveAttachmentUploadTarget(
const MG_State::GLState::FramebufferAttachmentObject& attachment) {
MobileGL::TextureUploadTarget resolved = attachment.GetTextureUploadTarget();
if (resolved != MobileGL::TextureUploadTarget::Unknown) return resolved;
const auto& texture = attachment.GetTexture();
if (!texture) return MobileGL::TextureUploadTarget::Unknown;
const auto& targets = texture->GetUploadTargets();
return targets.size() == 1 ? targets[0] : MobileGL::TextureUploadTarget::Unknown;
}
// ONE PURE FUNCTION, ONE STATEMENT PER FIELD, and that shape is a gate requirement rather
// than taste: G7's scripted control stops this conversion copying exactly one member
// (MGPSurface::Layered) and expects the framebuffer suite to go red NAMING that field. A
// loop or a memcpy would make the control unanswerable.
//
// `res` is handed in because resolving it needs the slot allocator and this function stays
// pure; `internalFormat` is INLINE in the record on purpose, so the four cross-object masks
// fall out at push time with no lookup on the far side.
// THE EMPTY POINT IS THE ZERO-INITIALISED RECORD EXCEPT FOR ITS TWO TARGET FIELDS. Both
// are Uint16 enumerations whose zero is a REAL value - TextureTarget::Texture1D and
// TextureUploadTarget::Texture1D - so a reader that forgot to gate on Kind would read a
// plausible wrong answer rather than a nonsense one. Unknown (0xFFFF) is what the contract
// spells for TextureTarget (kMGPipeSurfaceNoTextureTarget) and m6 applies the same rule to
// UploadTarget, which shares the collision ID-12 DV-3 ruled on for MGPSubData::Target.
inline MGPSurface MGPipeEmptySurface() {
MGPSurface surface{};
surface.UploadTarget = static_cast<Uint16>(MobileGL::TextureUploadTarget::Unknown);
surface.TextureTarget = kMGPipeSurfaceNoTextureTarget;
return surface;
}
inline MGPSurface MGPipeBuildSurface(const MG_State::GLState::FramebufferAttachmentObject& attachment,
MGPipeHandle res) {
MGPSurface surface = MGPipeEmptySurface();
if (attachment.IsEmpty()) return surface;
surface.Res = res;
if (attachment.IsTexture()) {
const auto& texture = attachment.GetTexture();
surface.Kind = kMGPipeSurfaceKindTexture;
surface.InternalFormat = static_cast<Uint32>(texture->GetFormat());
surface.Layered = attachment.IsLayered() ? 1 : 0;
surface.Level = static_cast<Uint16>(std::max<Int>(attachment.GetTextureLevel(), 0));
surface.Layer = static_cast<Uint32>(std::max<Int>(attachment.GetTextureLayer(), 0));
surface.UploadTarget = static_cast<Uint16>(MGPipeResolveAttachmentUploadTarget(attachment));
// ID-12 DV-5: the field that WAS Pad0, and the size did not move. The four
// cross-object masks all reduce to (format, TEXTURE TARGET) -
// ShouldUseCaveatTextureFormat / BackendTextureFormatAddsAlpha - and no
// TextureUploadTarget -> TextureTarget inverse exists anywhere in the tree, so
// without this the inline InternalFormat cannot make them fall out at push time and
// the backend keeps reading the frontend attachment objects.
surface.TextureTarget = static_cast<Uint16>(texture->GetTarget());
return surface;
}
const auto& renderbuffer = attachment.GetRenderbuffer();
surface.Kind = kMGPipeSurfaceKindRenderbuffer;
surface.InternalFormat = static_cast<Uint32>(renderbuffer->GetInternalFormat());
surface.Layered = 0;
surface.Level = 0;
surface.Layer = 0;
return surface;
}
// MGPFramebufferState::DrawBuffers[i]: an index INTO THIS RECORD'S OWN Color[] array, and
// -1 for NONE, which is the field's documented convention read literally.
//
// THE FOUR DEFAULT-FRAMEBUFFER TOKENS map to 0, and that is a deliberate narrowing rather
// than an oversight: a default framebuffer has one colour surface, this record carries it
// in Color[0] (see MGPipeBuildFramebufferState), and IsDefault is what tells the server
// which framebuffer it is looking at. The distinction the narrowing loses is FRONT versus
// BACK and LEFT versus RIGHT, which MobileGL's frontend never gives a default framebuffer
// in the first place - FramebufferObject's constructor seeds BackLeft and nothing writes
// another. A phase that needs stereo has to widen the field, not re-encode this one.
inline Int8 MGPipeDrawBufferIndex(MobileGL::FramebufferAttachmentType buffer) {
using MobileGL::FramebufferAttachmentType;
if (buffer == FramebufferAttachmentType::None) return -1;
if (buffer >= FramebufferAttachmentType::Color0 && buffer <= FramebufferAttachmentType::ColorMax) {
return static_cast<Int8>(static_cast<Int>(buffer) - static_cast<Int>(FramebufferAttachmentType::Color0));
}
return 0;
}
// m2: A DRAW-BUFFER TOKEN CAN NAME A COLOUR POINT THE RECORD CANNOT CARRY, and D-C3's
// refusal loop only ever scanned ATTACHMENTS. `glDrawBuffers(1, {GL_COLOR_ATTACHMENT10})`
// with nothing attached at 10 is legal state - draw-incomplete, but legal - and the index
// above would have written 10 into a record whose Color[] is 8 wide, so the server would
// index out of its own storage or invent a bound the record does not carry. Truncating
// silently is the bug class this phase is closing, so the record is refused exactly as an
// over-wide attachment is.
inline Bool MGPipeDrawBufferIsInsideTheWireWidth(MobileGL::FramebufferAttachmentType buffer) {
using MobileGL::FramebufferAttachmentType;
if (buffer < FramebufferAttachmentType::Color0 || buffer > FramebufferAttachmentType::ColorMax) {
return true; // None and the four default-framebuffer tokens; neither indexes Color[]
}
return static_cast<Int>(buffer) - static_cast<Int>(FramebufferAttachmentType::Color0) <
static_cast<Int>(kMGPipeMaxColorAttachments);
}
// ---------------------------------------------------------------------------------
// D-C4: ContentHash, and the one input it must not swallow
// ---------------------------------------------------------------------------------
//
// XXH64 over the WHOLE record with ContentHash itself zeroed, computed field-wise into a
// zero-initialised staging copy so that no padding byte can enter the hash. Two jobs: the
// server's render-pass memo key, and this client's emission suppressor.
//
// IT MUST COVER Fbo. A recycled framebuffer handle whose successor happens to carry an
// identical attachment set would otherwise be suppressed against its predecessor; Fbo
// carries Gen, so it cannot be.
//
// IT MUST COVER DrawBuffers[8], and this is the trap worth naming. The backend derives the
// fragColor BROADCAST COUNT from the draw-buffer array, and it does that at the verb, from
// the framebuffer state it then holds, precisely so a program can relink inside the same
// draw. A hash that did not cover the array would let a suppressed set_framebuffer_state
// mean "the draw buffers did not move" when they had, and the shader would be specialised
// for the previous output shape. With the array in the hash, a suppression provably means
// the array did not move, which provably means the broadcast count did not move.
inline void MGPipeCopySurfaceForHash(MGPSurface& dst, const MGPSurface& src) {
dst.Res = src.Res;
dst.InternalFormat = src.InternalFormat;
dst.Kind = src.Kind;
dst.Layered = src.Layered;
dst.Level = src.Level;
dst.Layer = src.Layer;
dst.UploadTarget = src.UploadTarget;
// MANDATORY, not optional: TextureTarget is a PipeFields.def row now, so a
// field-wise copy that skipped it would suppress a record whose only moved field is
// the attachment's texture target - and that field decides three of the four
// cross-object masks.
dst.TextureTarget = src.TextureTarget;
}
inline Uint64 MGPipeFramebufferStateContentHash(const MGPFramebufferState& state) {
MGPFramebufferState staging{};
staging.Fbo = state.Fbo;
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
MGPipeCopySurfaceForHash(staging.Color[i], state.Color[i]);
}
MGPipeCopySurfaceForHash(staging.Depth, state.Depth);
MGPipeCopySurfaceForHash(staging.Stencil, state.Stencil);
MGPipeCopySurfaceForHash(staging.ReadSurface, state.ReadSurface);
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
staging.DrawBuffers[i] = state.DrawBuffers[i];
}
staging.Width = state.Width;
staging.Height = state.Height;
staging.Layers = state.Layers;
staging.Samples = state.Samples;
staging.FixedSampleLocations = state.FixedSampleLocations;
staging.IsDefault = state.IsDefault;
staging.Complete = state.Complete;
staging.Target = state.Target;
// staging.ContentHash stays 0 - that is the whole point.
return XXH64(&staging, sizeof(staging), 0);
}
// ---------------------------------------------------------------------------------
// The emitter
// ---------------------------------------------------------------------------------
class MGPipeFramebufferEmitter {
public:
using GLContext = MG_State::GLState::GLContext;
using FramebufferObject = MG_State::GLState::FramebufferObject;
using FramebufferAttachmentType = MobileGL::FramebufferAttachmentType;
// The handle for `fbo`. kMGPipeDefaultFramebuffer ({0,1}) for the default framebuffer,
// which is what retires the four pDefaultFramebufferInfo->defaultFBO identity
// comparisons into an ordinary handle compare; a client-minted {slot, gen} otherwise.
//
// Minted, never gated: a framebuffer handle is CLIENT state and costs one free-list pop.
static MGPipeHandle HandleFor(const FramebufferObject& fbo) {
if (fbo.IsDefaultFramebuffer()) return kMGPipeDefaultFramebuffer;
return MGPipeSlots().Acquire(MGPipeKind::Framebuffer, fbo.GetLifetimeId());
}
// Returns the bytes that went on the wire, for the per-draw payload histogram.
Uint64 EmitFramebufferState(GLContext& ctx) {
if (!MGPipeFramebufferSubsystemEnabled()) return 0;
const auto& drawFbo = ctx.GetFramebufferBindingSlot(MobileGL::FramebufferTarget::Draw).GetBoundObject();
const auto& readFbo = ctx.GetFramebufferBindingSlot(MobileGL::FramebufferTarget::Read).GetBoundObject();
if (!drawFbo && !readFbo) return 0;
// ONE OBJECT BOUND TO BOTH TARGETS IS ONE RECORD WITH Target = Both, and that is
// not an optimisation: Espryt's "same FBO as draw" skip is the habitat of the
// read-buffer defect class, and a record that says which target it describes turns
// "apply the draw buffers only for the draw target" from call-site discipline into
// a one-line test on the far side.
const Bool shared = drawFbo && readFbo && drawFbo.get() == readFbo.get();
MGPFramebufferState drawState{};
MGPFramebufferState readState{};
Bool drawOk = false;
Bool readOk = false;
if (shared) {
drawOk = BuildFramebufferState(*drawFbo, MGPipeFramebufferTarget::Both, drawState);
} else {
if (drawFbo) {
drawOk = BuildFramebufferState(*drawFbo, MGPipeFramebufferTarget::Draw, drawState);
}
if (readFbo) {
readOk = BuildFramebufferState(*readFbo, MGPipeFramebufferTarget::Read, readState);
}
}
if (!drawOk && !readOk) return 0;
// THE SUPPRESSOR SLOT IS FED THE COMBINED ANSWER and the per-target latches decide
// which of the two records actually goes out. The slot exists so that
// InvalidateAll() on a fresh context reaches this family like every other, and so
// that "nothing moved" costs one compare rather than two.
const Uint64 drawHash = drawOk ? drawState.ContentHash : 0;
const Uint64 readHash = readOk ? readState.ContentHash : 0;
const Uint64 combined =
MGPipeMixShutter(MGPipeMixShutter(drawHash, readHash), shared ? 1u : 0u);
if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetFramebufferState,
combined)) {
return 0;
}
Uint64 bytes = 0;
if (shared) {
if (drawOk && (drawHash != m_lastEmitted[kDraw] || drawHash != m_lastEmitted[kRead])) {
bytes += Emit(drawState);
m_lastEmitted[kDraw] = drawHash;
m_lastEmitted[kRead] = drawHash;
}
return bytes;
}
if (drawOk && drawHash != m_lastEmitted[kDraw]) {
bytes += Emit(drawState);
m_lastEmitted[kDraw] = drawHash;
}
if (readOk && readHash != m_lastEmitted[kRead]) {
bytes += Emit(readState);
m_lastEmitted[kRead] = readHash;
}
return bytes;
}
// ID-19(c): EVERY DSA ENTRY POINT THAT HANDS A FRAMEBUFFER TO THE SERVER BY NAME IS
// PRECEDED BY A RECORD FOR IT, and that is the phase's main correction rather than a
// nicety. With only the two BOUND-target records, glClearNamedFramebufferfv(fbo) on an
// unbound fbo made the backend mint a fresh driver framebuffer with NO ATTACHMENTS,
// find no record for it, decline, and issue the clear against it anyway -
// GL_INVALID_FRAMEBUFFER_OPERATION and nothing cleared, where the legacy arm cleared
// correctly (esprytobj C-1).
//
// THE TARGET IS Named ONLY WHEN THE OBJECT IS BOUND TO NEITHER BINDING. A record always
// writes FramebufferRecords[Fbo.Slot]; Draw/Read/Both ADDITIONALLY set the bound
// handle(s). So handing a currently-bound framebuffer a Named record would overwrite
// the bound record's Target with one that says "no binding" while BoundFramebuffer
// still names it, and the server would read a record whose Target contradicts the
// binding it is resolved through. Re-asserting the binding the object already has is
// free (the content hash suppresses it) and keeps the two consistent.
//
// Returns the bytes that went on the wire.
Uint64 EmitFramebufferByName(const FramebufferObject& fbo) {
if (!MGPipeFramebufferSubsystemEnabled()) return 0;
MGPipeFramebufferTarget target = MGPipeFramebufferTarget::Named;
const Bool boundToDraw = IsBoundTo(fbo, MobileGL::FramebufferTarget::Draw);
const Bool boundToRead = IsBoundTo(fbo, MobileGL::FramebufferTarget::Read);
if (boundToDraw && boundToRead) {
target = MGPipeFramebufferTarget::Both;
} else if (boundToDraw) {
target = MGPipeFramebufferTarget::Draw;
} else if (boundToRead) {
target = MGPipeFramebufferTarget::Read;
}
MGPFramebufferState state{};
if (!BuildFramebufferState(fbo, target, state)) return 0;
// THE SUPPRESSOR IS KEYED BY THE FRAMEBUFFER THE RECORD NAMES, never by one global
// slot (MGPipeTypes.h states the rule): two different objects' Named records in a
// row must both go out, and a Named record must never be suppressed against the
// same object's bound record or the reverse. Target is a ContentHash input, so the
// second half holds by construction; the per-object table is what buys the first.
// The two BOUND latches stay what they are - "does the server's draw/read binding
// already hold this record" - and a bound-target emission from here consults them,
// because a rebind of an unchanged object must still move the binding.
if (target == MGPipeFramebufferTarget::Named) {
NamedEntry& entry = NamedEntryFor(state.Fbo);
if (entry.Has && entry.Gen == state.Fbo.Gen && entry.LastHash == state.ContentHash) {
return 0;
}
const Uint64 bytes = Emit(state);
entry.Has = true;
entry.Gen = state.Fbo.Gen;
entry.LastHash = state.ContentHash;
return bytes;
}
if (target == MGPipeFramebufferTarget::Both) {
if (state.ContentHash == m_lastEmitted[kDraw] && state.ContentHash == m_lastEmitted[kRead]) {
return 0;
}
const Uint64 bytes = Emit(state);
m_lastEmitted[kDraw] = state.ContentHash;
m_lastEmitted[kRead] = state.ContentHash;
return bytes;
}
const SizeT slot = target == MGPipeFramebufferTarget::Read ? kRead : kDraw;
if (state.ContentHash == m_lastEmitted[slot]) return 0;
const Uint64 bytes = Emit(state);
m_lastEmitted[slot] = state.ContentHash;
return bytes;
}
// ---- what a unit case reads. The emitter builds INTO these and hands the applier the
// same objects, so "what was emitted" costs no copy. ----
// ---- the death half (P4a final review C-2) ----
//
// Called by the contract's death helper before the slot is freed (there is no wire
// delete for this kind, D-I2, so this is the only client-side thing a framebuffer's
// death has to do). The per-object Named latch is the entry: a recycled handle's Gen
// already refuses the stale latch, so this is hygiene rather than a fix - the rule
// (ID-8) is that whatever mints a handle retires everything it keeps under it at the
// death, and every P4a kind takes the same shape. Gen-keyed for a late notice.
void NoteFramebufferDied(MGPipeHandle handle) {
const SizeT slot = handle.Slot;
if (MGPipeHandleIsNull(handle) || slot >= m_named.size()) return;
if (m_named[slot].Gen == handle.Gen) m_named[slot] = NamedEntry{};
}
// "Does this emitter hold a Named-record latch for this handle at its generation."
Bool NamedRecordIsLatched(MGPipeHandle handle) const {
const SizeT slot = handle.Slot;
if (MGPipeHandleIsNull(handle) || slot >= m_named.size()) return false;
return m_named[slot].Has && m_named[slot].Gen == handle.Gen;
}
const MGPFramebufferState& LastDraw() const { return m_lastDraw; }
const MGPFramebufferState& LastRead() const { return m_lastRead; }
const MGPFramebufferState& LastNamed() const { return m_lastNamed; }
Uint64 EmissionCount() const { return m_emissions; }
Uint64 RefusedCount() const { return m_refusals; }
// A fresh context: what the server has is no longer what this emitter last sent. Only
// LATCHES reset here - MGPipeApplierReset clears the applier's DrawFramebuffer and
// ReadFramebuffer working state, so these mirrors have to go with them or the first
// emission after a make-current would be suppressed as unchanged and the server would
// draw into the previous context's framebuffer. The suppressor slot is invalidated by
// the validate point's own InvalidateAll(), beside this call.
void Reset() {
m_lastEmitted[kDraw] = 0;
m_lastEmitted[kRead] = 0;
// The per-object latch goes too, and the safe direction is why: MGPipeApplierReset
// keeps FramebufferRecords standing (they are object state, ID-19(b)) but
// ReleaseObjectRecords clears the whole table, and this emitter cannot tell the two
// scopes apart from here. Keeping a latch across a table that may have been dropped
// would suppress the one record that had to go out; dropping it costs one extra
// 304-byte record per named framebuffer after a context switch.
m_named.clear();
}
void ResetCounters() { m_emissions = m_refusals = 0; }
void ResetForTest() {
Reset();
ResetCounters();
m_lastDraw = MGPFramebufferState{};
m_lastRead = MGPFramebufferState{};
m_lastNamed = MGPFramebufferState{};
}
private:
static constexpr SizeT kDraw = 0;
static constexpr SizeT kRead = 1;
Uint64 Emit(const MGPFramebufferState& state) {
if (state.Target == static_cast<Uint8>(MGPipeFramebufferTarget::Named)) {
m_lastNamed = state;
} else if (state.Target == static_cast<Uint8>(MGPipeFramebufferTarget::Read)) {
m_lastRead = state;
} else {
m_lastDraw = state;
if (state.Target == static_cast<Uint8>(MGPipeFramebufferTarget::Both)) m_lastRead = state;
}
MGPipeApplySetFramebufferState(state);
++m_emissions;
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::FramebufferEmissions, 1);
}
return sizeof(MGPFramebufferState);
}
// ONE RECORD DESCRIBES ONE FRAMEBUFFER OBJECT - the one named by `fbo` - and every
// field in it is a property of THAT object. Target is the only binding-specific one.
//
// ReadSurface IS RESOLVED FROM THIS FRAMEBUFFER'S OWN READ BUFFER UNDER EVERY TARGET,
// Named included (c0e / MGPipeTypes.h). v1 resolved a Draw record's ReadSurface from
// the READ-bound object, which was D-C2's letter and muddled in substance: the record
// then described a surface that is not part of the framebuffer its own Fbo names, and a
// glReadBuffer on the read FBO moved the DRAW record's ContentHash and forced a
// redundant draw emission. Resolving it per object is what makes the
// read-buffer-shared-FBO defect class unrepresentable rather than merely fixed - the
// record carries a surface, not an index, and no field of it refers to "whatever is
// bound".
Bool BuildFramebufferState(const FramebufferObject& fbo, MGPipeFramebufferTarget target,
MGPFramebufferState& out) {
// D-C3, THE CLIENT HALF OF THE BRING-UP REFUSAL. The wire array is 8 wide and
// GetDynamicParameters().MaxColorAttachments is the driver's raw ES cap, not
// clamped to 8 on the GLES path. An attachment point at or above the wire width
// cannot be carried at all, so the record is REFUSED and the legacy arm runs -
// truncating it silently is exactly the bug class this phase is closing. The
// backend half of the same refusal (bit 9 declined at its first lookup, with one
// ERROR naming the cap) rides ResolveFramebufferSubsystemArm.
for (Int point = static_cast<Int>(FramebufferAttachmentType::Color0) +
static_cast<Int>(kMGPipeMaxColorAttachments);
point <= static_cast<Int>(FramebufferAttachmentType::ColorMax); ++point) {
if (fbo.GetAttachment(static_cast<FramebufferAttachmentType>(point)).IsEmpty()) continue;
MGLOG_E_ONCE("MGPipe: framebuffer %u has an attachment at colour point %d, which is at or "
"above the wire width of %u - set_framebuffer_state is refused rather than "
"truncated and the legacy arm runs",
fbo.GetExternalIndex(),
point - static_cast<Int>(FramebufferAttachmentType::Color0),
static_cast<Uint>(kMGPipeMaxColorAttachments));
++m_refusals;
return false;
}
// m2, THE SAME REFUSAL ONE FIELD OVER. A draw-buffer token may name a colour point
// at or above the wire width with nothing attached there, which the loop above
// cannot see; MGPipeDrawBufferIndex would then write 8..31 into an 8-wide array.
{
const auto& tokens = fbo.GetDrawBuffers();
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
if (MGPipeDrawBufferIsInsideTheWireWidth(tokens[i])) continue;
MGLOG_E_ONCE("MGPipe: framebuffer %u names colour point %d in draw buffer %u, which is "
"at or above the wire width of %u - set_framebuffer_state is refused "
"rather than truncated and the legacy arm runs",
fbo.GetExternalIndex(),
static_cast<Int>(tokens[i]) -
static_cast<Int>(FramebufferAttachmentType::Color0),
static_cast<Uint>(i), static_cast<Uint>(kMGPipeMaxColorAttachments));
++m_refusals;
return false;
}
}
out = MGPFramebufferState{};
out.Fbo = HandleFor(fbo);
out.Target = static_cast<Uint8>(target);
out.IsDefault = fbo.IsDefaultFramebuffer() ? 1 : 0;
// THE COLOUR POINTS. A default framebuffer keeps its one colour surface under
// BackLeft rather than under Color0, and the record has exactly one place to put
// it: Color[0], which is also the index MGPipeDrawBufferIndex maps that token to,
// so the array and the draw-buffer indices agree by construction.
if (out.IsDefault != 0) {
out.Color[0] = SurfaceOf(fbo, FramebufferAttachmentType::BackLeft);
} else {
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
out.Color[i] = SurfaceOf(fbo, static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) +
static_cast<Int>(i)));
}
}
out.Depth = SurfaceOf(fbo, FramebufferAttachmentType::Depth);
out.Stencil = SurfaceOf(fbo, FramebufferAttachmentType::Stencil);
out.ReadSurface = SurfaceOf(fbo, fbo.GetReadBuffer());
const auto& drawBuffers = fbo.GetDrawBuffers();
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
out.DrawBuffers[i] = MGPipeDrawBufferIndex(drawBuffers[i]);
}
FillGeometry(fbo, out);
// Complete is FramebufferObject::CheckCompleteness(), the FRONTEND-ONLY answer, and
// never glCheckFramebufferStatus's: that entry point additionally consults the
// backend's probed format-capability cache, and a client emitting it would be
// reading the backend from the client side - the exact coupling this boundary
// exists to remove. glCheckFramebufferStatus keeps answering from the frontend
// exactly as it does today.
out.Complete = fbo.CheckCompleteness() ? 1 : 0;
out.ContentHash = MGPipeFramebufferStateContentHash(out);
return true;
}
static Bool IsBoundTo(const FramebufferObject& fbo, MobileGL::FramebufferTarget target) {
if (MG_State::pGLContext == nullptr) return false;
const auto& bound = MG_State::pGLContext->GetFramebufferBindingSlot(target).GetBoundObject();
return bound && bound.get() == &fbo;
}
struct NamedEntry {
Uint32 Gen = 0;
Uint64 LastHash = 0;
Bool Has = false;
};
NamedEntry& NamedEntryFor(MGPipeHandle fbo) {
const SizeT slot = fbo.Slot;
if (slot >= m_named.size()) m_named.resize(slot + 1);
return m_named[slot];
}
MGPSurface SurfaceOf(const FramebufferObject& fbo, FramebufferAttachmentType type) {
if (type == FramebufferAttachmentType::None || type == FramebufferAttachmentType::Unknown) {
return MGPipeEmptySurface();
}
const auto& attachment = fbo.GetAttachment(type);
if (attachment.IsEmpty()) return MGPipeEmptySurface();
MGPipeTextureEmitter& textures = MGPipeTextureEmitterInstance();
// D-A4's two producers: an attachment point is what sets RENDER_TARGET and
// DEPTH_STENCIL, the two sticky bind bits nothing set before P4a. Sticky and ORed,
// so a texture that was ever a colour attachment keeps saying so, and the mask is
// republished on the resource's next respecify.
const Uint16 bit = (type == FramebufferAttachmentType::Depth ||
type == FramebufferAttachmentType::Stencil)
? static_cast<Uint16>(kMGPipeBindDepthStencil)
: static_cast<Uint16>(kMGPipeBindRenderTarget);
MGPipeHandle res = kMGPipeNullHandle;
if (attachment.IsTexture()) {
const auto& texture = attachment.GetTexture();
res = textures.AcquireTexture(texture->GetLifetimeId(), texture.get());
textures.NoteTextureBoundAs(res, bit);
} else if (attachment.IsRenderbuffer()) {
const auto& renderbuffer = attachment.GetRenderbuffer();
res = textures.AcquireRenderbuffer(renderbuffer->GetLifetimeId());
textures.NoteRenderbufferBoundAs(res, bit);
}
return MGPipeBuildSurface(attachment, res);
}
// The attachments' common extent, and the ARB_framebuffer_no_attachments defaults when
// there is no attachment at all (GL 4.6 core table 23.24 - the shape a framebuffer with
// no attachments rasterizes at).
static void FillGeometry(const FramebufferObject& fbo, MGPFramebufferState& out) {
Bool found = false;
for (const auto& attachment : fbo.GetAllAttachmentObjects()) {
if (attachment.IsEmpty()) continue;
const IntVec3 size = attachment.GetSize();
if (!found) {
out.Width = static_cast<Uint16>(std::clamp<Int>(size.x(), 0, 0xFFFF));
out.Height = static_cast<Uint16>(std::clamp<Int>(size.y(), 0, 0xFFFF));
out.Layers = static_cast<Uint16>(
attachment.IsLayered() ? std::clamp<Int>(size.z(), 1, 0xFFFF) : 1);
if (attachment.IsTexture()) {
const auto& texture = attachment.GetTexture();
out.Samples = static_cast<Uint16>(std::max<Int>(texture->GetSamples(), 0));
out.FixedSampleLocations = texture->HasFixedSampleLocations() ? 1 : 0;
} else {
out.Samples = static_cast<Uint16>(
std::max<Int>(attachment.GetRenderbuffer()->GetSamples(), 0));
out.FixedSampleLocations = 1;
}
found = true;
}
}
if (found) return;
out.Width = static_cast<Uint16>(std::clamp<Int>(fbo.GetDefaultWidth(), 0, 0xFFFF));
out.Height = static_cast<Uint16>(std::clamp<Int>(fbo.GetDefaultHeight(), 0, 0xFFFF));
out.Layers = static_cast<Uint16>(std::clamp<Int>(fbo.GetDefaultLayers(), 0, 0xFFFF));
out.Samples = static_cast<Uint16>(std::clamp<Int>(fbo.GetDefaultSamples(), 0, 0xFFFF));
out.FixedSampleLocations = fbo.GetDefaultFixedSampleLocations() ? 1 : 0;
}
Array<Uint64, 2> m_lastEmitted{};
// The per-FRAMEBUFFER suppressor for Named records, slot-indexed with the generation
// checked, exactly as the applier's own table is. A framebuffer has no wire lifetime
// (D-I2), so a successor simply overwrites its predecessor's entry.
Vector<NamedEntry> m_named;
MGPFramebufferState m_lastDraw{};
MGPFramebufferState m_lastRead{};
MGPFramebufferState m_lastNamed{};
Uint64 m_emissions = 0;
Uint64 m_refusals = 0;
};
inline MGPipeFramebufferEmitter& MGPipeFramebufferEmitterInstance() {
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the
// rule covers every MGPipe process singleton, not only the ones a frontend destructor
// reaches today, and it is what keeps exit() out of a torn-down pipe.
static MGPipeFramebufferEmitter* emitter = new MGPipeFramebufferEmitter();
return *emitter;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+182
View File
@@ -0,0 +1,182 @@
// MobileGL - MobileGL/MG_Impl/Pipe/ImageEmit.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 set_shader_images, the third of P4a's kVarTail unit sets. It rides
// SamplerEmit.h's subsystem bit (kMGPipeWiredSamplerSubsystem): one family, one A/B.
//
// TWO INVARIANTS THAT MUST SURVIVE INTO THE BODY, and they are the kind an optimisation
// deletes:
// 1. THE HIGH-WATER-ZERO EARLY-OUT. An image high-water mark of 0 emits nothing, BEFORE any
// hash - that is what makes every Minecraft draw pay one integer test for a feature it
// does not use.
// 2. THE SWEEP'S GATE IS KEYED ON FRONTEND GENERATIONS AND DELIBERATELY NOT ON A BACKEND
// RE-MINT COUNTER. A texture bound ONLY to an image unit is re-minted INSIDE the sweep,
// so a server-side epoch would be bumped after the gate had already declined. The
// client's bit-14 shutter is Mix(Mix(textureContent, textureParams), programImageUnitVersion)
// - all three FRONTEND counters - so the property is preserved by construction, and it is
// written here because it is invisible from the shutter itself.
//
// The record carries the APPLICATION's format and access; the bind-format recast (a GL_RG32F
// bind is INVALID_VALUE on 19 of 26 non-core formats on Adreno) and the buffer-texture split
// view stay SERVER-side and unchanged. ContentHash therefore has to cover InternalFormat and
// Access as well as the binding, because the format the shader was built against is live
// glBindImageTexture state and the format-less image bake keys on it.
//
// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see
// FramebufferEmit.h for why, in full.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SamplerEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#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/Core.h>
#include <MG_State/GLState/TextureState/TextureState.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <xxhash.h>
namespace MobileGL::MG_Pipe {
// D-G3. Over the tail with Start and Count mixed in, the same shape the two sampler sets
// use - and it covers InternalFormat and Access because those are live glBindImageTexture
// state that the format-less image bake keys on, not decoration.
inline Uint64 MGPipeShaderImageSetContentHash(const MGPImageView* entries, Uint32 start, Uint32 count) {
Uint64 hash = XXH64(entries, static_cast<SizeT>(count) * sizeof(MGPImageView), 0);
hash = MGPipeMixShutter(hash, start);
hash = MGPipeMixShutter(hash, count);
return hash;
}
class MGPipeImageEmitter {
public:
using GLContext = MG_State::GLState::GLContext;
// set_shader_images. Start is 0 and Count is the image-unit window described below.
//
// WHERE THE HIGH-WATER MARK COMES FROM, because the frontend has none and this is the
// one place a reader will look for it. DirectGLES keeps g_imageUnitHighWaterMark, but
// that is written from inside its own per-unit sync and lives on the far side of the
// boundary; TextureState::NoteUnitTouched is the TEXTURE-unit path and
// glBindImageTexture does not reach it. Adding a counter to TextureState would edit
// another package's file and resize the pull build's object, which G1 forbids outright.
//
// So the window is derived instead, from the one thing that decides whether an image
// unit can matter at all: the highest image unit the CURRENT PROGRAM names, memoised
// per program state in SamplerEmit.h's shared inversion, UNIONED with a sticky mark of
// every unit this emitter has already described. A program with no image uniforms
// gives MaxImageUnit == -1 and, with nothing sticky yet, a window of 0 - which is the
// zero early-out, taken BEFORE any hash and before any 192-entry walk, exactly as
// property 1 requires. The mark is sticky so that a program which stops naming a unit
// does not silently stop describing it: the window only grows, and shrinking it is how
// a stale binding would become invisible to the server.
Uint64 EmitShaderImages(GLContext& ctx) {
const auto& program = ctx.GetProgramForDraw();
const auto& resolution = MGPipeProgramOpaqueUnitsShared().For(program.get());
const Uint32 programWindow =
resolution.MaxImageUnit < 0 ? 0u : static_cast<Uint32>(resolution.MaxImageUnit) + 1u;
if (programWindow > m_window) m_window = programWindow;
const Uint32 count = m_window < kMGPipeMaxImageUnits ? m_window : kMGPipeMaxImageUnits;
// PROPERTY 1, and it is one integer test on every draw of every application that
// never binds an image.
if (count == 0) return 0;
for (Uint32 unit = 0; unit < count; ++unit) {
const auto& binding = ctx.GetImageTextureBinding(static_cast<Int>(unit));
MGPImageView& entry = m_entries[unit];
entry = MGPImageView{};
entry.Unit = unit;
entry.Res = binding.Texture ? MGPipeSlots().Acquire(MGPipeKind::Texture,
binding.Texture->GetLifetimeId())
: kMGPipeNullHandle;
// D-A4: a texture named in an emitted MGPImageView is SHADER-IMAGE-bound from
// then on - the bit ImageBindableHint is derived from. The bind itself noted it
// first (TextureState.h, so the hint precedes the first sync); this is the
// letter of the rule and a one-compare early-out once the bit is set.
if (!MGPipeHandleIsNull(entry.Res)) {
MGPipeNoteTextureBoundAs(entry.Res, static_cast<Uint32>(kMGPipeBindShaderImage));
}
// THE APPLICATION's format and access, verbatim. The bind-format recast and the
// buffer-texture split view are server-side and stay there; so does
// SupportsLayeredImageBinding's rule, which asks the BACKEND target after
// MapToBackendTextureTarget and forces layer to 0 for a non-layerable one -
// Adreno took a stray layer index literally. A client that pre-applied any of
// that would be answering a driver question from the wrong side.
entry.InternalFormat = static_cast<Uint32>(binding.Format);
entry.Layer = static_cast<Uint32>(binding.Layer);
entry.Level = static_cast<Uint16>(binding.Level);
entry.Layered = binding.Layered != GL_FALSE ? 1 : 0;
entry.Access = static_cast<Uint8>(MGPipeEncodeImageAccess(binding.Access));
}
const Uint64 hash = MGPipeShaderImageSetContentHash(m_entries.data(), 0, count);
if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetShaderImages, hash)) {
return 0;
}
m_lastImages = MGPShaderImages{};
m_lastImages.Start = 0;
m_lastImages.Count = count;
m_lastImages.ContentHash = hash;
MGPipeApplySetShaderImages(m_lastImages, m_entries.data());
++m_imageSets;
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ShaderImageEmissions, 1);
}
return sizeof(MGPShaderImages) + static_cast<Uint64>(count) * sizeof(MGPImageView);
}
// The validate point's FreshlyPrimed arm. A fresh context is a fresh set of image
// bindings, so the sticky window starts over; the suppressor slot this set latches is
// invalidated beside this call. There is no record half here at all - set_shader_images
// is pure working state and mints no object of its own.
void Reset() { m_window = 0; }
void ResetCounters() { m_imageSets = 0; }
const MGPShaderImages& LastShaderImages() const { return m_lastImages; }
const Array<MGPImageView, kMGPipeMaxImageUnits>& LastImageViews() const { return m_entries; }
Uint64 ImageSetCount() const { return m_imageSets; }
Uint32 Window() const { return m_window; }
private:
// GL_READ_ONLY / GL_WRITE_ONLY / GL_READ_WRITE folded into the one byte the wire
// carries. A value the enum does not name would otherwise truncate silently into a
// Uint8, which is the class of bug the descriptors exist to close.
static Uint32 MGPipeEncodeImageAccess(GLenum access) {
switch (access) {
case GL_READ_ONLY:
return 0;
case GL_WRITE_ONLY:
return 1;
case GL_READ_WRITE:
return 2;
default:
MOBILEGL_ASSERT(false, "glBindImageTexture access 0x%x is not one of the three GL names",
static_cast<Uint>(access));
return 0;
}
}
Array<MGPImageView, kMGPipeMaxImageUnits> m_entries{};
MGPShaderImages m_lastImages{};
Uint32 m_window = 0;
Uint64 m_imageSets = 0;
};
inline MGPipeImageEmitter& MGPipeImageEmitterInstance() {
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason; heap-constructed and
// intentionally leaked at exit, like every other MGPipe process singleton.
static MGPipeImageEmitter* emitter = new MGPipeImageEmitter();
return *emitter;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
File diff suppressed because it is too large Load Diff
+102 -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,63 @@ namespace MobileGL::MG_Pipe {
// stop where it says it stops (MG_Test/ScopedPipeVerb.h).
void MGPipeLeaveVerb();
// PipeFill.cpp. DOES THIS BUILD, ON THIS BACKEND, AT THIS MASK, EMIT FOR THIS P4a FAMILY?
// (ID-39, widened by S-3 / ID-41.) The four conjuncts are the operator's per-subsystem bit
// in MOBILEGL_PIPE_PUSH, the family's own kMGPipeWired*Subsystem constant (`wired`, which
// the caller passes because it lives in the family's emit header and this header may not
// include one), and - for the four families P4a migrates - a backend having registered
// MGPipeResourceOps (the same per-backend signal `MGPipeResourceSubsystemEnabled()` has
// applied to P3a's buffers since the phase began) and every D-K2 dependency bit of the
// family being set in the same mask.
//
// THE LAST TWO CONJUNCTS ARE THE ONES THIS DECLARATION EXISTS FOR, and they are the same
// defect twice. Magma (DirectVulkan) registers no table and has no P4a twins; at a mask like
// 0x7ff Espryt REFUSES the texture family server-side because D-K2's fourth row says bit 10
// requires bit 11. In both cases the client emitted anyway, the applier accepted, the
// emitters cleared their per-level dirty flags on that acceptance, and the legacy upload
// path that still owed those texels found nothing to upload (66 DirectVulkan cases at ID-39,
// 47 DirectGLES cases at ID-41). With them the four families emit NOTHING in that state and
// the legacy pull path runs exactly as it does on a pull build.
//
// D-K2's TABLE IS IN PipeFill.cpp, ONCE: bit 9 requires bit 10, bit 10 requires bits 7 and
// 11, bit 11 requires bit 10, bit 12 depends on nothing - the client mirror, bit for bit, of
// the four `Resolve<Family>SubsystemArm()` refusals in DirectGLES/Managers.cpp.
//
// It is exported for the unit gate and for no other caller: the gate itself is
// FamilyIsLive() inside PipeFill.cpp, every birth hook and every `wants()` row resolves
// through it, and this returns that same expression rather than a second copy of it.
Bool MGPipeP4aFamilyEmits(Uint64 subsystem, Uint64 wired);
// 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 +108,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 +138,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
+484
View File
@@ -0,0 +1,484 @@
// MobileGL - MobileGL/MG_Impl/Pipe/ProgramEmit.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 P4a's program family: create/bind/delete_shader_state,
// set_draw_program, set_dispatch_program and set_global_constants.
//
// WHERE create_shader_state IS EMITTED FROM, and why it is not the tracker's business: the
// tracker's bit-6 shutter reads GetCurrentProgram() and DELIBERATELY NOT GetProgramForDraw(),
// because the tracker must not force a compile just to answer "did the shader move". So the
// tracker keeps its shutter and the EMITTER joins - from the same GetProgramForDraw() /
// GetProgramForDispatch() call the verb is about to make anyway, so no join happens that would
// not have happened. Emitting from the compile pool's terminal continuation is a real
// asynchronous win and is a LATER phase's: in monolith the applier is one function call away,
// so it is unmeasurable here.
//
// WHAT THE SERVER STILL SPECIALISES, so nobody reads create_shader_state as self-contained
// and produces a per-draw rebuild: the draw-FBO clamp masks, the fragColor broadcast count,
// the storage-block binding signature, the atomic-counter set, the live image formats and the
// patch parameters are all inputs a backend program depends on BEYOND the artefacts. This call
// publishes the ARTEFACTS; the server specialises at the verb from the state it holds. The
// clause count does not shrink - its inputs move.
//
// THE ARTEFACTS DO NOT TRAVEL IN MONOLITH. All seven of MGPProgramDesc's blob refs are
// declared with Size 0 and the LinkArtifacts / SpirvArtifacts ride beside the record through
// MGPipeApplyCreateShaderState's companion pointers, so the codec is never called on the hot
// path; the verify build is where it is exercised.
//
// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see
// FramebufferEmit.h for why, in full.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/CompositeResolver.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/MGPipe.h>
#include <MG_Pipe/MGPipeHostSpan.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/ProgramState/ProgramObject.h>
#include <MG_Util/Metrics/PipeStats.h>
namespace MobileGL::MG_Pipe {
// WIRED. create/bind/delete_shader_state, set_draw_program, set_dispatch_program and
// set_global_constants all have bodies, so this family contributes its bit to
// kMGPipeWiredSubsystems.
//
// AND SINCE c0b THAT CONSTANT REALLY IS PART OF THE EMISSION GATE, so the note that used to
// say otherwise here was true only against the contract commit: the validate point's
// `wants()` asks the subsystem mapping, the operator's MOBILEGL_PIPE_PUSH mask, THIS
// CONSTANT and the dirty bit, and the birth hooks' `FamilyIsLive` asks the same pair one
// level in. It is also a compile-time contract - while it is non-zero PipeFill.cpp's
// `if constexpr` seam instantiates the forward to EmitShaderCso below, so a missing entry
// point is a build error here rather than at the merge. The RUNTIME A/B that switches the
// family off is still the mask. See SamplerEmit.h's twin note.
inline constexpr Uint64 kMGPipeWiredProgramSubsystem = kMGPipeSubsystemPrograms;
// D-H6. ~0u is the BACKENDS' "never uploaded" sentinel for a global-constants version, and
// ProgramObject::MarkUBOContentDirty skips it on the wrap for exactly that reason. The
// client must never put it on the wire either: a server that received it would read its own
// record as "nothing has ever been uploaded here" and re-upload for ever.
inline constexpr Uint32 kMGPipeGlobalConstantsNeverUploaded = ~Uint32{0};
inline constexpr Bool MGPipeGlobalConstantsVersionIsEmittable(Uint32 version) {
return version != kMGPipeGlobalConstantsNeverUploaded;
}
// A program's identity for the wire, out of the SNAPSHOT the last link consumed and never
// out of the live attach list: glAttachShader and glCompileShader take effect only at the
// NEXT link and neither moves m_linkVersion, so a stage mask built from GetAttachedShaders
// would describe a program that does not exist yet. GetLinkedShaderStages() is also what
// indexes GetGeneratedSpirv(), so the two halves of this descriptor are guaranteed to agree
// by construction rather than by care.
inline Uint32 MGPipeStageMaskOf(const MG_State::GLState::ProgramObject& program) {
Uint32 mask = 0;
for (const ShaderStage stage : program.GetLinkedShaderStages()) {
if (stage == ShaderStage::Unknown) continue;
mask |= Uint32{1} << static_cast<Uint32>(stage);
}
return mask;
}
class MGPipeProgramEmitter {
public:
using GLContext = MG_State::GLState::GLContext;
using ProgramObject = MG_State::GLState::ProgramObject;
// create_shader_state (re-issued on the SAME handle whenever the link version moves -
// Gen moves only on slot reuse), then bind_shader_state and set_draw_program /
// set_dispatch_program. Two program calls because the frontend has two joins and two
// PipeInputs slots.
//
// BOTH JOINS HAPPEN HERE and both are the verb's own: GetProgramForDraw flattens a
// bound pipeline into its composite and GetProgramForDispatch answers the compute
// question, and with a plain glUseProgram they are the same object, so the ordinary
// frame pays one join it was going to pay anyway.
Uint64 EmitShaderState(GLContext& ctx) {
Uint64 bytes = 0;
const auto& drawProgram = ctx.GetProgramForDraw();
const auto& dispatchProgram = ctx.GetProgramForDispatch();
const MGPipeHandle drawCso =
drawProgram ? AcquireShaderCso(*drawProgram, bytes) : kMGPipeNullHandle;
// THE COMPOSITE'S SECOND RELEASE PATH is spoken here, not in a destructor: when the
// bound pipeline's draw-program signature moves, the resolver releases the slot the
// previous composite held. Whichever of the two paths runs second - this one or the
// composite ProgramObject's own ~ProgramObject - is a proven no-op, because the slot
// allocator refuses a slot that is not live at that generation.
if (drawProgram && MGPipeProgramIsPipelineComposite(*drawProgram)) {
if (const auto& pipeline = ctx.GetBoundProgramPipeline()) {
// THE CONTEXT IS PART OF THE RESOLVER's KEY and this is the only place that
// supplies it: the resolver is a process singleton and a pipeline's GL name
// is per context, so without it a make-current between two contexts holding
// one pipeline name released the other context's LIVE composite.
// GetTextureContextId() is the tree's never-reused per-context id, the same
// one PipeInputs carries and the backends' per-context memos key on.
MGPipeCompositeResolverInstance().Observe(ctx.GetTextureContextId(), *pipeline,
*drawProgram, drawCso);
}
}
const MGPipeHandle dispatchCso =
dispatchProgram ? (dispatchProgram == drawProgram ? drawCso
: AcquireShaderCso(*dispatchProgram, bytes))
: kMGPipeNullHandle;
// THE BOUND CSO IS THE DRAW ONE WHEN THERE IS ONE. bind_shader_state names what
// glUseProgram selected, and when a program pipeline is bound instead that is the
// composite; a compute-only pipeline has no draw program at all, and then the
// dispatch program is the only thing bound. A null handle is legal here and means
// exactly "nothing bound".
const MGPipeHandle boundCso = !MGPipeHandleIsNull(drawCso) ? drawCso : dispatchCso;
if (boundCso != m_boundCso) {
MGPipeApplyBindShaderState(HandleOnly(boundCso));
m_boundCso = boundCso;
++m_binds;
bytes += sizeof(MGPHandleOnly);
}
if (drawCso != m_drawCso) {
MGPipeApplySetDrawProgram(HandleOnly(drawCso));
m_drawCso = drawCso;
++m_drawSets;
bytes += sizeof(MGPHandleOnly);
}
if (dispatchCso != m_dispatchCso) {
MGPipeApplySetDispatchProgram(HandleOnly(dispatchCso));
m_dispatchCso = dispatchCso;
++m_dispatchSets;
bytes += sizeof(MGPHandleOnly);
}
return bytes;
}
// set_global_constants: the DEFAULT UNIFORM BLOCK only, keyed (ShaderCso, Version) and
// at most once per program per frame. Version is GetUBOContentVersion() and must never
// be ~0u, which is the backends' "never uploaded" sentinel - the wrap skips it.
//
// NAMED uniform blocks are NOT this call's: set_shader_buffers(Uniform) is a later
// phase's and BindCurrentProgramWithResources' named-UBO block is untouched. What
// travels here is globalUboScratch, the link phase's CPU array, which has no GL name
// and no BufferObject behind it.
Uint64 EmitGlobalConstants(GLContext& ctx) {
const auto& program = ctx.GetProgramForDraw();
if (!program) return 0;
const Uint32 version = program->GetUBOContentVersion();
// THE SENTINEL IS NEVER EMITTED. A server that received ~0u would read its own
// record as "never uploaded" and re-upload every frame for ever.
if (!MGPipeGlobalConstantsVersionIsEmittable(version)) return 0;
const Uint size = program->GetUBOSize();
if (size == 0) return 0;
Uint64 bytes = 0;
const MGPipeHandle cso = AcquireShaderCso(*program, bytes);
// (ShaderCso, Version) IS the key, so the latch is the key: an unchanged pair means
// the server already holds these bytes and re-sending them would move the record's
// serial for nothing.
if (cso == m_constantsCso && version == m_constantsVersion) return bytes;
m_lastConstants = MGPGlobalConstants{};
m_lastConstants.ShaderCso = cso;
m_lastConstants.Version = version;
// THE ONE BLOB RULE: Size 0 means "this record does not declare its blob" - which
// is what a monolith emission is - and the bytes ride beside it as a companion
// pointer. Offset carries the staging address for diagnostics only; nothing reads
// it as a length.
m_lastConstants.Blob.Seg = kMGHostSpanSegNone;
m_lastConstants.Blob.Offset = reinterpret_cast<Uint64>(program->GetUBOData());
m_lastConstants.Blob.Size = 0;
MGPipeApplySetGlobalConstants(m_lastConstants, program->GetUBOData());
m_constantsCso = cso;
m_constantsVersion = version;
++m_constantSets;
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::CsoBlobBytes, size);
}
return bytes + sizeof(MGPGlobalConstants) + size;
}
// D-H4's re-issue rule, and it is the CreateVertexElements shape one for one: the
// record goes out again on the SAME handle whenever the link version moves, which is
// legal because MGPipeHandle::Gen increments only on slot reuse and never on a
// respecify. A program that relinks is the same GL object and the server's twin table
// must not be asked to mint a second one.
MGPipeHandle AcquireShaderCso(const ProgramObject& program, Uint64& payloadBytes) {
const MGPipeHandle handle = AcquireShaderCsoHandle(program);
if (MGPipeHandleIsNull(handle)) return handle;
Latch& latch = LatchFor(handle);
const Uint32 linkVersion = program.GetLinkVersion();
if (latch.RecordLive && latch.RecordGen == handle.Gen && latch.LinkVersion == linkVersion) {
return handle;
}
const auto& link = program.GetLinkReflection();
const auto& spirv = program.GetSpirvReflection();
m_lastDesc = MGPProgramDesc{};
m_lastDesc.Cso = handle;
m_lastDesc.StageMask = MGPipeStageMaskOf(program);
m_lastDesc.GlobalUboSize = static_cast<Uint32>(program.GetUBOSize());
m_lastDesc.ReservedNumSamplesOffset = static_cast<Uint32>(spirv.reservedNumSamplesOffset);
m_lastDesc.SpirvStatus = spirv.spirvStatus ? 1 : 0;
m_lastDesc.NativeFloat64 = spirv.nativeFloat64 ? 1 : 0;
m_lastDesc.PointSizeDemoted = spirv.pointSizeDemoted ? 1 : 0;
m_lastDesc.EnableSpirvValidation = spirv.enableSpirvValidation ? 1 : 0;
// ONE BLOB REF PER MODULE, IN THE LINKED-SHADER-SNAPSHOT'S ORDER, which is the
// order GetGeneratedSpirv() is indexed in - so Spirv[i] and StageMask agree because
// they came out of the same snapshot. Every one of them declares Size 0 (the one
// Blob rule); Offset carries the module's staging address so a reader can see which
// slots are occupied without the record pretending to declare a length it does not
// own.
//
// A COUNTED REFUSAL AND NOT AN ASSERTION (D-J3). MOBILEGL_ASSERT compiles out at
// INFO, which is all three gate builds and every shipped build, so an assert here
// would leave the truncation below completely silent in exactly the builds that
// run - which is the idiom D-J3 exists to forbid. generatedSpirv cannot exceed six
// stages today, so this is a guard against a seventh; truncation is the safe
// direction and the counter is what makes it visible.
const SizeT moduleCount = spirv.generatedSpirv.size();
if (moduleCount > 6) ++m_moduleTruncations;
for (SizeT i = 0; i < moduleCount && i < 6; ++i) {
m_lastDesc.Spirv[i].Seg = kMGHostSpanSegNone;
m_lastDesc.Spirv[i].Offset = reinterpret_cast<Uint64>(spirv.generatedSpirv[i].data());
m_lastDesc.Spirv[i].Size = 0;
}
m_lastDesc.Reflection.Seg = kMGHostSpanSegNone;
m_lastDesc.Reflection.Offset = reinterpret_cast<Uint64>(&link);
m_lastDesc.Reflection.Size = 0;
MGPipeApplyCreateShaderState(m_lastDesc, &link, &spirv);
// THE CREATE WENT OUT, so the publication latch is taken here and nowhere else
// (contract-v2 §3.1). MGPipeEmitShaderCsoDestroyAndFree reads it, and without it
// delete_shader_state can never go out - for an ordinary program or for a
// composite, both of which take that one helper.
MGPipeNoteHandlePublished(MGPipeKind::ShaderCso, handle);
++m_creates;
payloadBytes += sizeof(MGPProgramDesc);
// A RE-ISSUED create_shader_state CLEARS THE APPLIER's DEFAULT UNIFORM BLOCK (wire
// W6), so the (Cso, Version) latch that suppresses set_global_constants has to go
// with it or the block is never re-sent. The case the design worries about is a
// FAILED relink of a bound program - GL keeps the previous executable and its
// uniforms running - and the general one is any future re-issue trigger that does
// not happen to move the content version, of which a recycled slot is one.
// Invalidated rather than re-emitted here, because this function has no business
// deciding when the constants go out: the next EmitGlobalConstants sees an
// unlatched key and sends them.
if (m_constantsCso == handle) {
m_constantsCso = kMGPipeNullHandle;
m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
}
latch.RecordLive = true;
latch.RecordGen = handle.Gen;
latch.LinkVersion = linkVersion;
return handle;
}
// ---- THE CONTRACT ENTRY POINT THIS FAMILY OWES (contract-v2 §3.4) ----
//
// PipeFill.cpp's MGPipeEmitShaderCsoCreate forwards here through the `if constexpr`
// seam keyed on kMGPipeWiredProgramSubsystem, so while that constant is non-zero this
// must exist and be spelled exactly like this. A thin wrapper on purpose:
// AcquireShaderCso above IS this family's handle rule - identity-addressed per
// ProgramObject, the composite band entered through the one door, the re-issue on the
// same handle and the publication - and a second copy of any of it here would be a
// second authority.
//
// THE HOOK HAS ALREADY APPLIED BOTH GATES (the operator's mask and the wired constant),
// so this body applies none of its own. The byte count is discarded: a birth is not a
// validate-point emission and has no payload budget to report into.
void EmitShaderCso(ProgramObject& program) {
Uint64 bytes = 0;
AcquireShaderCso(program, bytes);
}
// The emitter's OWN record memo - "have I already published a create_shader_state at
// this slot, for this generation, at this link version".
//
// IT IS NOT WHAT THE DEATH PATH ASKS, and that changed at c0b (contract-v2 §3.1/D17):
// MGPipeEmitShaderCsoDestroyAndFree reads A's publication latch, which is one answer
// per {kind, slot, gen} that all six death helpers share. This stays because the
// VERSION-FIRST SKIP needs it - it is the same latch AcquireShaderCso consults before
// it builds a descriptor - and because a unit case reads it.
//
// THE COMPOSITE BAND IS INDEXED SEPARATELY, for the allocator's own reason: the band
// base is 983040, so a slot-indexed vector would allocate ~983k latches for one program
// pipeline. Both spaces stay dense against their own high-water mark.
Bool RecordIsPublished(MGPipeHandle handle) const {
if (MGPipeHandleIsNull(handle)) return false;
const Vector<Latch>& table = TableOf(handle);
const SizeT slot = SlotIndexOf(handle);
if (slot >= table.size()) return false;
const Latch& latch = table[slot];
return latch.RecordLive && latch.RecordGen == handle.Gen;
}
// The memo's other half, and the bound-mirror clearing beside it.
//
// THE CALLER IS THE CONTRACT's DEATH HELPER (P4a final review C-2): the death path
// reads the contract's latch for the wire delete and then forwards here, before the
// slot is freed, so a dead handle no longer reads as published in this memo between
// the death and the recycle and the three bound mirrors never name a dead program.
// Gen-keyed, so a late notice for a slot already handed out again clears nothing of
// the successor's.
void NoteRecordDestroyed(MGPipeHandle handle) {
if (MGPipeHandleIsNull(handle)) return;
Vector<Latch>& table = TableOf(handle);
const SizeT slot = SlotIndexOf(handle);
if (slot < table.size() && table[slot].RecordGen == handle.Gen) {
table[slot] = Latch{};
}
if (m_boundCso == handle) m_boundCso = kMGPipeNullHandle;
if (m_drawCso == handle) m_drawCso = kMGPipeNullHandle;
if (m_dispatchCso == handle) m_dispatchCso = kMGPipeNullHandle;
if (m_constantsCso == handle) {
m_constantsCso = kMGPipeNullHandle;
m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
}
}
// The validate point's FreshlyPrimed arm. MGPipeApplierReset clears DrawProgram,
// DispatchProgram and BoundShaderCso - all three are per-context WORKING STATE - so
// the three mirrors here go with them, or the first emission after a make-current
// would be suppressed as unchanged and the server would draw with the previous
// context's program bound.
//
// The RECORD half stays, and that is the rule rather than an oversight: the applier
// keeps its shader-CSO records across a make-current because a program lives in a share
// group, and re-publishing one would move its Serial for nothing. The global-constants
// key goes with the working state because its record's bytes are per (Cso, Version) and
// a fresh server has not been told them.
void Reset() {
m_boundCso = kMGPipeNullHandle;
m_drawCso = kMGPipeNullHandle;
m_dispatchCso = kMGPipeNullHandle;
m_constantsCso = kMGPipeNullHandle;
m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
// The composite memo's freshness goes with them - and only its freshness. Its
// ENTRIES name composites whose frontend objects outlive the context switch, so
// releasing them here would emit a delete for a live program.
MGPipeCompositeResolverInstance().Reset();
}
void ResetCounters() {
m_creates = m_binds = m_drawSets = m_dispatchSets = m_constantSets = 0;
m_moduleTruncations = 0;
}
// ---- what a unit case reads ----
const MGPProgramDesc& LastProgramDesc() const { return m_lastDesc; }
const MGPGlobalConstants& LastGlobalConstants() const { return m_lastConstants; }
// THE (Cso, Version) KEY set_global_constants is suppressed against. Exposed so a case
// can pin that a re-issued create_shader_state invalidates it - the applier clears the
// block on the re-issue (wire W6), so a latch that survived it would never re-send.
MGPipeHandle GlobalConstantsCso() const { return m_constantsCso; }
Uint32 GlobalConstantsVersion() const { return m_constantsVersion; }
// D-J3's counted refusal: programs whose linked snapshot carried more modules than
// MGPProgramDesc::Spirv[] can name, and whose tail was therefore dropped.
Uint64 TruncatedModuleCount() const { return m_moduleTruncations; }
MGPipeHandle BoundCso() const { return m_boundCso; }
MGPipeHandle DrawCso() const { return m_drawCso; }
MGPipeHandle DispatchCso() const { return m_dispatchCso; }
Uint64 CreateCount() const { return m_creates; }
Uint64 BindCount() const { return m_binds; }
Uint64 DrawProgramSetCount() const { return m_drawSets; }
Uint64 DispatchProgramSetCount() const { return m_dispatchSets; }
Uint64 GlobalConstantsSetCount() const { return m_constantSets; }
private:
// THE ONE PLACE THE BAND CAN ENTER. An ordinary program's slot comes from the ordinary
// allocator door keyed on its lifetime id. CompositeResolver.h widens this to send a
// pipeline composite through MGPipeSlotAllocator::AllocateComposite instead, and
// nothing else about the emission changes - the server never learns a composite is a
// composite.
MGPipeHandle AcquireShaderCsoHandle(const ProgramObject& program) {
const Uint64 lifetimeId = program.GetLifetimeId();
const MGPipeHandle existing = MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId);
if (!MGPipeHandleIsNull(existing)) return existing;
// A composite is minted off ITS OWN lifetime id, out of the reserved band, and is
// an ordinary ShaderCso handle in every other respect - the same kind, the same
// {slot, gen} rules, the same Free, the same death helper. Keying it on its own
// lifetime id rather than on the pipeline's signature is what makes ~ProgramObject
// able to release it at all, and it is why two pipelines that happen to have the
// same signature keep their own composite: sharing one handle between two frontend
// objects would let the first one's death free a slot the second still names.
return MGPipeProgramIsPipelineComposite(program)
? MGPipeSlots().AllocateComposite(lifetimeId)
: MGPipeSlots().AllocateFor(MGPipeKind::ShaderCso, lifetimeId);
}
struct Latch {
Bool RecordLive = false;
Uint32 RecordGen = 0;
Uint32 LinkVersion = 0;
};
// TWO TABLES, NOT A WIDER ONE, and it is the allocator's own reason repeated where it
// bites a second time: the composite band starts at slot 983040, so folding a composite
// into the ordinary slot-indexed vector would allocate ~983k latches - and grow them
// again on every future push_back - for a single program pipeline. Both spaces stay
// dense against their own high-water mark, which is exactly what the allocator does one
// level down.
Vector<Latch>& TableOf(MGPipeHandle handle) {
return MGPipeIsCompositeShaderSlot(handle.Slot) ? m_compositeLatch : m_latch;
}
const Vector<Latch>& TableOf(MGPipeHandle handle) const {
return MGPipeIsCompositeShaderSlot(handle.Slot) ? m_compositeLatch : m_latch;
}
static SizeT SlotIndexOf(MGPipeHandle handle) {
return MGPipeIsCompositeShaderSlot(handle.Slot)
? static_cast<SizeT>(handle.Slot - kMGPipeShaderCsoCompositeSlotBase)
: static_cast<SizeT>(handle.Slot);
}
Latch& LatchFor(MGPipeHandle handle) {
Vector<Latch>& table = TableOf(handle);
const SizeT slot = SlotIndexOf(handle);
if (slot >= table.size()) table.resize(slot + 1);
return table[slot];
}
static MGPHandleOnly HandleOnly(MGPipeHandle handle) {
MGPHandleOnly only{};
only.Handle = handle;
only.Kind = static_cast<Uint32>(MGPipeKind::ShaderCso);
return only;
}
MGPProgramDesc m_lastDesc{};
MGPGlobalConstants m_lastConstants{};
Vector<Latch> m_latch;
Vector<Latch> m_compositeLatch;
MGPipeHandle m_boundCso = kMGPipeNullHandle;
MGPipeHandle m_drawCso = kMGPipeNullHandle;
MGPipeHandle m_dispatchCso = kMGPipeNullHandle;
MGPipeHandle m_constantsCso = kMGPipeNullHandle;
Uint32 m_constantsVersion = kMGPipeGlobalConstantsNeverUploaded;
Uint64 m_creates = 0;
Uint64 m_binds = 0;
Uint64 m_drawSets = 0;
Uint64 m_dispatchSets = 0;
Uint64 m_constantSets = 0;
Uint64 m_moduleTruncations = 0;
};
inline MGPipeProgramEmitter& MGPipeProgramEmitterInstance() {
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason; heap-constructed and
// intentionally leaked at exit, and it MUST NOT hold a frontend SharedPtr - that is
// the exit-order rule, stated over every MGPipe process singleton rather than over the
// ones a destructor reaches today.
static MGPipeProgramEmitter* emitter = new MGPipeProgramEmitter();
return *emitter;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+615
View File
@@ -0,0 +1,615 @@
// 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 MOVED TO MG_Pipe/MGPipeTypes.h AT P4a, beside
// the field, exactly as the note that stood here said they would when a second producer
// appeared: P4a's texture family sets kMGPipeBindSampler / kMGPipeBindShaderImage /
// kMGPipeBindRenderTarget / kMGPipeBindDepthStencil, the four bits nothing set before.
// No alias is written for them because none is possible or needed - both files are
// namespace MobileGL::MG_Pipe and this one includes that header, so every spelling below
// and in package B's code is unchanged.
//
// What stays here is the BUFFER half of the mapping, which is this file's own: the
// BufferTarget table, its sentinel and its completeness assert.
// 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
// ---------------------------------------------------------------------------------
//
// P4a MINTED THE FIRST LIST: MGPipeTypes.h now carries enum MGPipeResourceTarget beside
// the field, and kMGPipeResourceTargetBuffer moved there with it - the narrowed
// resource_respecify ack predicate lives in that header and has to name the buffer target
// explicitly, and it may not reach into MG_Impl to do so. The second discriminator is the
// frontend enum, named rather than open-coded, and stays here because only this file
// produces it.
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() {
// NEVER DESTROYED, for MGPipeSlots()' reason (SlotAllocator.cpp): ~BufferObject reads
// and writes this tracker, and the objects that own the last reference to a
// BufferObject outlive every function-local static.
static MGPipeResourceTracker* tracker = new MGPipeResourceTracker();
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
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
// 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. P4a wires SetSamplerViews,
// BindSamplerStates and SetShaderImages, and APPENDS an eighth slot, SetFramebufferState -
// which leaves only SetShaderBuffers and SetStreamOutputTargets unwired, both P4b's.
//
// 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), PLUS
// SetFramebufferState, which is not kVarTail at all: MGPFramebufferState carries a
// ContentHash for TWO jobs - the server's render-pass memo key and the client's emission
// suppressor - and the second one needs a slot here like any other. The enum is
// CLIENT-ONLY and is not a wire opcode, so appending before Count is safe.
enum class MGPipeSuppressorSlot : Uint32 {
SetVertexBuffers = 0, // P3a - wired, and its hash includes BaseInstance
// P4a - WIRED. The three unit sets' suppressors are not optional and were never a
// later phase's: MGPipeTypes.h makes the pattern mandatory for every kVarTail set_*,
// because GetTextureBindGeneration() bumps on a REDUNDANT rebind - MC 26.2 rebinds the
// same sampler at every texture-unit switch - so an unsuppressed set is a
// several-hundred-byte variable-length record per batch, which is the exact regression
// the design names. What P3b/P4b owns is the ~175-line BACKEND debounce these replace
// (UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and the two
// g_*SyncList tables); P4a wires the carrier, P3b/P4b deletes the backend copy.
SetSamplerViews, // P4a - wired (backend debounce deletion: P3b/P4b)
BindSamplerStates, // P4a - wired (backend debounce deletion: P3b/P4b)
SetShaderImages, // P4a - wired (backend debounce deletion: P3b/P4b)
SetShaderBuffers, // P4b
SetStreamOutputTargets, // P4b
SetVertexAttribDefaults, // P2 - the one consumer that is wired
SetFramebufferState, // P4a - 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() {
// NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the
// rule covers every MGPipe process singleton, not only the ones on today's death
// paths.
static MGPipeSetHashSuppressor* suppressor = new MGPipeSetHashSuppressor();
return *suppressor;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+280
View File
@@ -0,0 +1,280 @@
// 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];
}
MGPipeSlotAllocator::SlotState* MGPipeSlotAllocator::EntryOf(KindState& state, MGPipeKind kind,
Uint32 slot) {
if (kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(slot)) {
const SizeT index = slot - kMGPipeShaderCsoCompositeSlotBase;
if (index >= state.BandSlots.size()) return nullptr;
return &state.BandSlots[index];
}
if (slot >= state.Slots.size()) return nullptr;
return &state.Slots[slot];
}
const MGPipeSlotAllocator::SlotState*
MGPipeSlotAllocator::EntryOf(const KindState& state, MGPipeKind kind, Uint32 slot) {
return EntryOf(const_cast<KindState&>(state), kind, slot);
}
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::AllocateComposite(Uint64 lifetimeId) {
// P4a, D-H7. The mirror image of Allocate() above, restricted to the band that one
// refuses, and kept in a table of its own so both spaces stay DENSE: the band's base
// is 983040, and minting one composite into the slot-indexed vector would allocate
// ~23 MB of SlotState for a single program pipeline.
KindState& state = StateOf(MGPipeKind::ShaderCso);
Uint32 slot = 0;
Bool reused = false;
if (!state.BandFreeList.empty()) {
slot = state.BandFreeList.back();
state.BandFreeList.pop_back();
reused = true;
}
if (!reused) {
const SizeT next = kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size();
slot = static_cast<Uint32>(next);
// The band's own exhaustion assert, mirroring Allocate()'s: a composite that
// cannot be minted is a NAMED failure, not a silent fall-through into the ordinary
// program slots, which is exactly what reserving a band rather than setting a flag
// buys.
MOBILEGL_ASSERT(next < kMGPipeShaderCsoSlotLimit,
"the MGPipe ShaderCso COMPOSITE band is exhausted at slot %zu; a "
"program-pipeline composite cannot be minted and must not take an "
"ordinary program's slot",
next);
if (next >= kMGPipeShaderCsoSlotLimit) return kMGPipeNullHandle;
state.BandSlots.emplace_back();
}
SlotState* entry = EntryOf(state, MGPipeKind::ShaderCso, slot);
if (entry == nullptr) return kMGPipeNullHandle;
if (entry->EverHandedOut) {
MOBILEGL_ASSERT(entry->Gen != ~Uint32{0},
"MGPipe handle generation wrapped on the ShaderCso composite band, "
"slot %u; {slot, gen} is no longer unique",
slot);
++entry->Gen;
}
entry->EverHandedOut = true;
entry->Live = true;
entry->LifetimeId = lifetimeId;
++state.LiveCount;
// The band's share of LiveCount, so CompositeLiveCount() can answer without a walk.
++state.BandLiveCount;
if (lifetimeId != 0) {
MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(),
"lifetime id %llu already owns a ShaderCso slot",
static_cast<unsigned long long>(lifetimeId));
state.ByLifetimeId[lifetimeId] = slot;
}
return MGPipeHandle{slot, entry->Gen};
}
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 SlotState* entry = EntryOf(state, kind, it->second);
if (entry == nullptr || !entry->Live) return kMGPipeNullHandle;
return MGPipeHandle{it->second, entry->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);
SlotState* entry = EntryOf(state, kind, handle.Slot);
if (entry == nullptr) return;
// A stale handle must not free the slot its successor now owns - that is the whole
// reason the generation is in the key. It is also what makes the SECOND of a
// composite's two independent release paths a proven no-op.
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;
if (kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(handle.Slot)) {
--state.BandLiveCount;
state.BandFreeList.push_back(handle.Slot);
} else {
state.FreeList.push_back(handle.Slot);
}
}
Bool MGPipeSlotAllocator::IsLive(MGPipeKind kind, MGPipeHandle handle) const {
const SlotState* entry = EntryOf(StateOf(kind), kind, handle.Slot);
return entry != nullptr && entry->Live && entry->Gen == handle.Gen;
}
Uint32 MGPipeSlotAllocator::GenOfSlot(MGPipeKind kind, Uint32 slot) const {
const SlotState* entry = EntryOf(StateOf(kind), kind, slot);
return entry != nullptr ? entry->Gen : 0;
}
Uint64 MGPipeSlotAllocator::LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const {
const SlotState* entry = EntryOf(StateOf(kind), kind, slot);
return entry != nullptr ? entry->LifetimeId : 0;
}
Uint32 MGPipeSlotAllocator::HighWater(MGPipeKind kind) const {
// THE ORDINARY SPACE ONLY, and the band is reported by CompositeHighWater() below.
// Folding the two would pin this at ~983k from the first composite mint onward and
// take the ordinary space's "the high-water mark did not move" assertion away for the
// rest of the process - the assertion that catches a dense table that never shrinks,
// which is the leak shape this allocator exists to make visible. Two spaces, two
// numbers, two real assertions. See SlotAllocator.h.
return static_cast<Uint32>(StateOf(kind).Slots.size());
}
Uint32 MGPipeSlotAllocator::CompositeHighWater() const {
const KindState& state = StateOf(MGPipeKind::ShaderCso);
// One past the highest composite slot ever handed out; exactly the base when none ever
// was, so the number is monotone from the first mint and a LEAKED COMPOSITE MOVES IT.
return static_cast<Uint32>(kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size());
}
Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; }
Uint32 MGPipeSlotAllocator::CompositeLiveCount() const {
return StateOf(MGPipeKind::ShaderCso).BandLiveCount;
}
Uint32 MGPipeSlotAllocator::FreeCount(MGPipeKind kind) const {
const KindState& state = StateOf(kind);
return static_cast<Uint32>(state.FreeList.size() + state.BandFreeList.size());
}
Uint32 MGPipeSlotAllocator::CompositeFreeCount() const {
return static_cast<Uint32>(StateOf(MGPipeKind::ShaderCso).BandFreeList.size());
}
void MGPipeSlotAllocator::Reset() {
for (KindState& state : m_kinds) {
state.Slots.clear();
state.FreeList.clear();
state.BandSlots.clear();
state.BandFreeList.clear();
state.ByLifetimeId.clear();
state.LiveCount = 0;
state.BandLiveCount = 0;
}
}
MGPipeSlotAllocator& MGPipeSlots() {
// NEVER DESTROYED, deliberately (one allocation for the life of the process). A
// frontend object's destructor reaches this allocator - ~BufferObject through
// MGPipeEmitResourceDestroyAndFree, ~VertexArrayObject through the death notice - and
// MG_Backend/MGPipe/PipeInputs.h's gPipeInputs holds SharedPtrs to those objects at
// namespace scope, so they are destroyed by __run_exit_handlers AFTER this
// function-local static would have been. A destroyed allocator then answers
// FindByLifetimeId out of a freed hash table and Free() writes into freed vectors -
// an exit-time heap corruption whose fatality depends only on the allocator's layout.
static MGPipeSlotAllocator* allocator = new MGPipeSlotAllocator();
return *allocator;
}
} // namespace MobileGL::MG_Pipe
+166
View File
@@ -0,0 +1,166 @@
// 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);
// P4a, D-H7: THE ONE ENTRY POINT INTO THE ShaderCso COMPOSITE BAND, and the only one
// there will ever be. Allocate() above refuses that band on purpose, so a program
// pipeline's flattened composite - minted client-side from the stage programs bound to
// the pipeline object, and indistinguishable from an ordinary program to the server -
// needs a door of its own rather than a flag on the handle. The kind is implied: only
// ShaderCso has a band.
//
// It behaves exactly like AllocateFor in every other respect (free list first, then
// the band's own high-water mark; Gen moves only on reuse; the lifetimeId -> slot map
// is written) and it carries the band's own exhaustion assert, so exhausting the
// composite space is a NAMED Fatal rather than silent slot theft from ordinary
// programs. Returns kMGPipeNullHandle when the band is full.
//
// Freed through the ordinary Free(MGPipeKind::ShaderCso, handle): a composite's slot
// has two independent release paths - the pipeline cache's LRU eviction and the
// composite ProgramObject's own destructor - and Free refusing a slot that is not live
// at that generation is what makes the second one a proven no-op.
MGPipeHandle AllocateComposite(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 ORDINARY slot ever handed out of this kind. For every kind but
// ShaderCso that is the whole story; for ShaderCso the composite band is a second,
// separately dense space and CompositeHighWater() below answers it.
//
// THE TWO SPACES ARE REPORTED SEPARATELY, and that is the point rather than a detail.
// Folding the band into this number pins it at ~983k from the first composite mint
// onward, and every later assertion of the "the high-water mark did not move over N
// churn rounds" shape - the one that catches a dense table that never shrinks, which
// is the ~1.3 KB-per-record leak C-1 produced - becomes vacuously true for ordinary
// ShaderCso slots for the rest of the process. A leak case per space is two real
// assertions; one merged number is one real assertion and one that cannot go red.
//
// It is also NOT a table size for kind ShaderCso even now: the band is sparse against
// the ordinary space by design, so a consumer indexing by slot must test
// MGPipeIsCompositeShaderSlot(slot) first and keep the band in a table of its own,
// exactly as this allocator does.
Uint32 HighWater(MGPipeKind kind) const;
// One past the highest COMPOSITE slot ever handed out, i.e.
// kMGPipeShaderCsoCompositeSlotBase + (band slots ever handed out), and exactly the
// base when none ever was. Kind ShaderCso is the only kind with a band, so it is
// implied - as it is for AllocateComposite. A LEAKED COMPOSITE MOVES THIS and moves
// nothing else, which is what the composite's own leak case asserts on.
Uint32 CompositeHighWater() const;
// Live slots of this kind, ORDINARY AND COMPOSITE TOGETHER for ShaderCso: a live
// composite is a live ShaderCso, the applier's two record tables are one object class,
// and a caller asking "how many shader CSOs does this client hold" wants both. The
// band's own count is CompositeLiveCount(); the ordinary space's is the difference.
Uint32 LiveCount(MGPipeKind kind) const;
Uint32 CompositeLiveCount() const;
// Slots waiting on a free list. Also BOTH SPACES for ShaderCso, for LiveCount's
// reason and with the same caveat: a caller that needs to know WHICH space a slot went
// back to reads CompositeFreeCount() and subtracts.
Uint32 FreeCount(MGPipeKind kind) const;
Uint32 CompositeFreeCount() 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;
// P4a: the ShaderCso COMPOSITE band, indexed by (slot - the band's base) and
// EMPTY for every other kind. A SECOND VECTOR RATHER THAN MORE OF THE FIRST, and
// it is not a micro-optimisation: the band starts at 983040, so minting one
// composite into the slot-indexed vector above would allocate ~983k SlotStates -
// ~23 MB - for a single program pipeline, and a consumer that sized a table off
// HighWater would pay the same shape again with a far bigger record. Both spaces
// stay dense against their own high-water mark, which is the property this
// allocator exists to give the server.
Vector<SlotState> BandSlots;
Vector<Uint32> BandFreeList;
UnorderedMap<Uint64, Uint32> ByLifetimeId;
Uint32 LiveCount = 0;
// The band's share of LiveCount above, so the two spaces can be reported apart
// without walking either table. Always 0 for every kind but ShaderCso.
Uint32 BandLiveCount = 0;
};
KindState& StateOf(MGPipeKind kind);
const KindState& StateOf(MGPipeKind kind) const;
// The SlotState a (kind, slot) names, in whichever of the two vectors holds it, or
// null when the slot has never been handed out. One resolver, so a caller that forgets
// the band cannot exist.
static SlotState* EntryOf(KindState& state, MGPipeKind kind, Uint32 slot);
static const SlotState* EntryOf(const KindState& state, MGPipeKind kind, Uint32 slot);
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
File diff suppressed because it is too large Load Diff
+864
View File
@@ -0,0 +1,864 @@
// 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 P4a
// adds SEVEN: 6, 7 and 8 (the program family), 11 (the framebuffer) and 12, 13 and 14 (the
// three unit sets). Only bits 15, 16 and 17 - the const-buffer, shader-buffer and
// stream-output sets - are still computed, latched and counted without an emitter, so the
// per-bit fire rate is a measurement rather than a plan and their fields go through the
// residual fill until P4b.
//
// P4a NARROWS NOTHING AND WIDENS THREE THINGS, and every one of them was an UNDER-FIRE that
// only became reachable once the bit gained an emitter:
// (1) bit 11's shutter gains the READ framebuffer binding slot's version, because
// set_framebuffer_state is emitted per bound TARGET and a glBindFramebuffer(
// GL_READ_FRAMEBUFFER, ...) moved no shutter at all before;
// (2) bit 13's gains the TEXTURE BIND generation, because glBindSampler moves that one and
// not the sampling-resolution one, so bind_sampler_states could not see a sampler bind;
// (3) bits 6/7/8 - and with them bit 14's program half - read the EFFECTIVE program source
// instead of GetCurrentProgram() alone, which is null for the whole life of a bound
// separable program pipeline, so a re-composited pipeline reached no program emitter.
// Over-firing is free; all three of those were the other direction.
//
// 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 and the other three from
// P4a - the program family, one subsystem, three bits because the frontend moves them
// as three separate events ----
NewVertexElements, // the bound VAO's attribute configuration -> create/bind_vertex_elements
NewShader, // the current program's link version -> create/bind_shader_state,
// set_draw_program, set_dispatch_program (P4a)
NewShaderBindings, // image units, block bindings, uniform write set (P4a)
NewGlobalConstants, // the default-uniform-block image -> set_global_constants (P4a)
// ---- 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 NEXT
// FOUR ARE P4a's. The last three are still computed and counted only, until P4b. ----
NewVertexBuffers, // -> set_vertex_buffers (P3a)
NewIndexBuffer, // -> set_index_buffer (P3a)
NewFramebuffer, // -> set_framebuffer_state, per bound target (P4a)
NewSamplerViews, // -> set_sampler_views (P4a)
NewSamplers, // -> bind_sampler_states (P4a)
NewShaderImages, // -> set_shader_images (P4a)
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);
// The SEVEN P4a adds, across FOUR subsystems: bits 6/7/8 are the program family, 11 the
// framebuffer, and 12/13/14 the sampler-view / sampler-state / image-unit sets. Added
// rather than edited into the two above, for the reason those two exist: each phase's
// constant survives as the next phase's A/B control and as what a test compares the
// subsystem map against.
//
// EVERY ONE OF THESE SHUTTERS WAS ALREADY COMPUTED, LATCHED AND COUNTED before P4a; what
// P4a adds is an emitter for them. That is why this is a one-line constant and not seven
// new shutters - and it is also why the two narrowings below are stated as requirements.
inline constexpr Uint32 kMGPipeDirtyEmittedAtP4a =
kMGPipeDirtyEmittedAtP3a | MGPipeDirtyBit(MGPipeDirty::NewShader) |
MGPipeDirtyBit(MGPipeDirty::NewShaderBindings) |
MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants) |
MGPipeDirtyBit(MGPipeDirty::NewFramebuffer) | MGPipeDirtyBit(MGPipeDirty::NewSamplerViews) |
MGPipeDirtyBit(MGPipeDirty::NewSamplers) | MGPipeDirtyBit(MGPipeDirty::NewShaderImages);
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;
// P4a's seven, across four subsystems. FOUR AND NOT ONE for P3a's reason one level
// out: a framebuffer path that regressed, a texture path that regressed, a sampler
// path that regressed and a program path that regressed are four different findings.
//
// The program family is three bits because the frontend moves them separately - a
// relink, a binding change and a uniform write are three events - but one subsystem,
// because an operator switching programs off has to get the whole family's legacy arm.
// Same for the three unit sets: create_sampler_state, create_sampler_view and the
// three kVarTail sets are one family, and half of it is not a control.
case MGPipeDirty::NewShader:
case MGPipeDirty::NewShaderBindings:
case MGPipeDirty::NewGlobalConstants:
return kMGPipeSubsystemPrograms;
case MGPipeDirty::NewFramebuffer:
return kMGPipeSubsystemFramebuffer;
case MGPipeDirty::NewSamplerViews:
case MGPipeDirty::NewSamplers:
case MGPipeDirty::NewShaderImages:
return kMGPipeSubsystemSamplers;
// NO BIT NAMES kMGPipeSubsystemTextureResources, and that is deliberate rather than an
// omission: the texture and renderbuffer resource_* calls and set_texture_params are
// dispatched from the GL entry points that cause them - a constructor, a storage
// definition, a glTexParameter - not from a dirty walk, exactly as P3a's buffer family
// is. Bit 10 gates those dispatch sites; there is no dirty bit to map onto it and
// there must not be one, or the emission would be gated twice and disagree with itself.
default:
// The remaining bits have no call of their own until 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).
//
// BUT GetCurrentProgram() ALONE IS NOT THE PROGRAM SOURCE, AND AT P4a THAT IS AN
// UNDER-FIRE. Under GL_ARB_separate_shader_objects an application drives
// `glUseProgram(0); glBindProgramPipeline(P)`, and m_currentProgram is then null
// for the whole life of that pipeline (Core.cpp, GetProgramForDraw's second half):
// all three of these shutters read 0 == 0 forever, so after the first walk on a
// fresh context - the one !m_primed fires unconditionally - bits 6, 7 and 8 never
// fire again however the pipeline is restaged.
//
// WHILE NOTHING WAS EMITTED FOR THEM THAT WAS INVISIBLE, which is how it survived
// to P4a: GetProgramForDraw is emitted-and-still-pulled, the residual fill copies
// it at every verb, and DirtySurface.def rules BindProgramPipelineObject
// kPulledEveryVerb for exactly that reason - the backend still receives the right
// SharedPtr and nothing renders wrong. The moment P4a emits off these bits it
// stops being invisible: glUseProgramStages rebuilds the composite, EmitShaderState
// is never called again, so the new composite gets no ShaderCso handle and no
// create_shader_state while set_draw_program keeps naming the previous one - a
// program the handle protocol never announced, which is exactly the seam-defect
// class P3a spent a phase closing. And bit 8 never firing means
// set_global_constants is never sent for a pipeline draw at all, where the pull
// rescues nothing.
//
// SO THE SHUTTER READS THE EFFECTIVE SOURCE: the program in use when there is one,
// and the bound pipeline when there is not. What it reads OF that pipeline is the
// pair ComputeDrawProgramSignature() is built from - each stage program's lifetime
// id and LINK version - so bit 6 fires exactly when GetProgramForDraw would hand
// back a different composite, which is exactly when a new ShaderCso handle has to
// be minted. Those are the same non-artefact fields the plain-program arm above
// reads, and the ones Core.cpp calls out as not passing through ProgramObject's
// join gate, so the "must not force a compile" rule survives intact: no join, no
// flatten, no Link().
//
// THE PIPELINE NAME IS MIXED IN because two pipelines can carry the same stage set
// and each caches its OWN composite object, so the signature alone would let a
// glBindProgramPipeline between two such pipelines pass without a fire. What that
// does NOT close is a name RECYCLED (glDeleteProgramPipelines +
// glGenProgramPipelines) back onto the same stage programs at the same link
// versions with no other program-family change in between: a ProgramPipelineObject
// has no lifetime id and no wire object at all - DirtySurface.def says so where it
// rules MarkProgramPipelineForDeletion kUnpublishedDestroy - so there is nothing
// else here to mix it with. Recorded rather than quietly left: closing it needs a
// generation counter on the frontend object, which is an MG_State change and not
// this file's to make.
const auto& program = ctx.GetCurrentProgram();
Uint64 shader = 0;
Uint64 bindings = 0;
Uint64 constants = 0;
Uint64 programImages = 0;
// THE PROGRAM INPUT OF THE PROGRAM-RESOLVED VIEW SET (P4a fable seam F-1).
// set_sampler_views is resolved for the program in use (SamplerEmit.h: the sampler
// uniform's TYPE picks which of a unit's targets is the view) and the emitter
// memoises that resolution on (lifetime id, link version, backend state version). A
// shutter that read only the texture generations therefore missed a glUseProgram:
// `glBindTexture x N; glUseProgram(P1); draw; glUseProgram(P2); draw` moved nothing
// bit 12 read, so the view set stayed P1's - and E's record epoch, keyed on the two
// set serials, then never rebuilt the texture sync list for P2 either. This value is
// that memo key, and bit 12 mixes it in below: over-firing costs one re-resolution
// the set-hash suppressor absorbs, under-firing left the record describing the
// previous program's units.
Uint64 opaqueUnits = 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());
// THE IDENTITY IS MIXED IN (P4a fable seam F-2), exactly as the pipeline arm
// below mixes stageLinks into its half: the counter alone is a per-program
// number two programs routinely share - 0 == 0 for any pair that never moved an
// image unit through glUniform1i, and 0 == 0 against no program at all - so a
// glUseProgram between them fired nothing, set_shader_images' window stayed the
// previous program's, and a program whose only image is a BUFFER image (E's
// SD-4: nothing else moves between the bind and the dispatch) never reached the
// record at all.
programImages = MGPipeMixShutter(shader, program->GetImageUnitVersion());
opaqueUnits = MGPipeMixShutter(shader, program->GetBackendStateVersion());
} else if (const auto& pipeline = ctx.GetBoundProgramPipeline(); pipeline) {
using Pipeline = MG_State::GLState::ProgramPipelineObject;
// THE FIELDS ARE READ DIRECTLY RATHER THAN THROUGH THE TWO FUNCTIONS THAT
// ALREADY PACK THEM, and that is a gate constraint, not a preference. Calling
// ComputeDrawProgramSignature() / ComputeUniformMirrorVersions() would say
// "the same pairs the composite cache and the uniform-mirror gate compare"
// far better than this loop does - but gen_pipe_dirty_surface.py derives a
// shutter by following each accessor to the member it returns, and both of
// those build a LOCAL array and return that, which it cannot place. A shutter
// naming them is UNRESOLVED, and then every DirtySurface.def row that names
// bits 6, 7, 8 or 14 loses its verdict - including the derivation that is the
// only mechanism able to catch the next under-fire here. So the pairs are
// spelled out, and the two static_asserts below are what say they must stay in
// step with the functions they mirror.
static_assert(sizeof(Pipeline::DrawProgramSignature) ==
2 * Pipeline::kGraphicsStageCount * sizeof(Uint64),
"bit 6 reads the {lifetimeId, linkVersion} pair per graphics "
"stage that ComputeDrawProgramSignature packs");
static_assert(sizeof(Pipeline::UniformMirrorVersions) ==
2 * Pipeline::kGraphicsStageCount * sizeof(Uint64),
"bits 7 and 8 read the four counters per graphics stage that "
"ComputeUniformMirrorVersions packs");
// Bit 6 is the pipeline's identity plus the composite cache key. Bits 7 and 8
// add the per-program state, which under a pipeline is written to the STAGE
// programs - glUniform* addresses the pipeline's active program,
// glProgramUniform* and the two block-binding calls address a named one - and
// only reaches the composite through RefreshCompositeUniforms. Bit 14's half
// takes the image-unit generation, which is its own counter for the reason
// ProgramObject gives (ES forbids glUniform1i on an image uniform, so Espryt
// BAKES the unit into the ESSL it generates and only a regeneration honours a
// change) and which D-G4 asks this shutter to keep reading as a FRONTEND
// counter rather than any server-side epoch.
//
// STAGELINKS IS MIXED INTO ALL THREE OF THE OTHERS, ON PURPOSE. A composite
// REBUILD hands back a brand-new ProgramObject with an empty default uniform
// block and no backend state at all - SetCachedDrawProgram clears the mirror
// versions with it - so a shutter watching only the per-stage state counters
// would let a rebuilt composite inherit the bindings, the constants and the
// image units of the one it replaced.
Uint64 stageLinks = static_cast<Uint64>(ctx.GetBoundProgramPipelineName());
Uint64 stageState = 0;
Uint64 stageImages = 0;
// The per-stage sampler/image unit assignments alone (glUniform1i on a stage
// program's sampler moves its backend state version and reaches the composite
// through the uniform mirror), for bit 12's program input below.
Uint64 stageOpaque = 0;
for (SizeT stage = 0; stage < Pipeline::kGraphicsStageCount; ++stage) {
const auto& staged = pipeline->GetStageProgram(static_cast<ShaderStage>(stage));
if (!staged) continue;
stageLinks = MGPipeMixShutter(
MGPipeMixShutter(stageLinks, staged->GetLifetimeId()), staged->GetLinkVersion());
stageState = MGPipeMixShutter(
MGPipeMixShutter(MGPipeMixShutter(stageState, staged->GetBackendStateVersion()),
MGPipeMixShutter(staged->GetUBOContentVersion(),
staged->GetBlockBindingVersion())),
staged->GetUniformWriteSetVersion());
stageImages = MGPipeMixShutter(stageImages, staged->GetImageUnitVersion());
stageOpaque = MGPipeMixShutter(stageOpaque, staged->GetBackendStateVersion());
}
shader = stageLinks;
stageState = MGPipeMixShutter(stageLinks, stageState);
bindings = MGPipeMixShutter(stageState, stageImages);
constants = stageState;
programImages = MGPipeMixShutter(stageLinks, stageImages);
opaqueUnits = MGPipeMixShutter(stageLinks, stageOpaque);
}
now[Index(MGPipeDirty::NewShader)] = shader;
now[Index(MGPipeDirty::NewShaderBindings)] = bindings;
now[Index(MGPipeDirty::NewGlobalConstants)] = constants;
// ===========================================================================
// THE RECORD-FIELD -> SETTER -> SHUTTER TABLE FOR THE SEVEN P4a BITS.
//
// THE RULE (P4a fable seam audit, section C.1): every field of every emitted
// record names the frontend setter that changes it, and that setter moves a
// counter the emitting bit's shutter reads - or the emission is unconditional at
// the setter (the resource_* family, set_texture_params). A record field whose
// setter moves no shutter input is a stale record with nothing to refuse: c0d
// (bit 13 without the bind generation), SD-0 (an image re-bind), F-1 (the
// program behind the view set), F-2 (the program behind the image window) and
// F-3 (an attached object's storage) were all this one class. DirtySurface.def
// cannot catch it - it maps MUTATORS to bits and cannot see that a DERIVED field
// depends on a mutator whose row is another family's - so the table lives here,
// beside the shutters, and a row is added whenever a record gains a field.
//
// bit 6 create/bind_shader_state, set_draw/dispatch_program (ProgramEmit.h)
// fields: Cso, StageMask, GlobalUboSize, the artefact blob refs, the two
// bound handles
// setters: glUseProgram (m_currentProgram), glLinkProgram (link version),
// glBindProgramPipeline / glUseProgramStages (pipeline name +
// per-stage {lifetime id, link version})
// shutter: lifetime id x link version, or stageLinks under a pipeline
// bit 7 the program's bindings (image units, block bindings, uniform write set)
// setters: glUniform1i on an opaque uniform (backend state version, image
// unit version), glUniformBlockBinding / glShaderStorageBlockBinding
// (block binding version), any glUniform* (uniform write set)
// shutter: the four per-program counters, x stageLinks under a pipeline
// bit 8 set_global_constants: ShaderCso, Version, the default-block image
// setters: any glUniform* on the default block (UBO content version),
// glUseProgram (lifetime id)
// shutter: lifetime id x UBO content version, or stageState
// bit 11 set_framebuffer_state: Fbo, Color[8]/Depth/Stencil/ReadSurface
// (Res, Kind, InternalFormat, TextureTarget, Layered, Level, Layer,
// UploadTarget), DrawBuffers[8], Width/Height/Layers/Samples/
// FixedSampleLocations, IsDefault, Complete, Target
// setters: glFramebufferTexture*/glFramebufferRenderbuffer, glDrawBuffer(s),
// glReadBuffer, glFramebufferParameteri (the attachment
// aggregate); glBindFramebuffer (the two binding slot versions);
// AND a storage redefinition of an ATTACHED texture or
// renderbuffer - glTexImage*/glTexStorage*/glTexBuffer/
// glTextureView/glRenderbufferStorage* - because InternalFormat,
// TextureTarget, the extent, Samples and Complete are INLINED at
// emission (D-C1): those bump the attachment aggregate from the
// object's PipePublishDescriptor (F-3)
// shutter: attachment aggregate x draw bind version x read bind version
// bit 12 set_sampler_views: per unit {View, Texture}
// setters: glBindTexture / glActiveTexture (bind generation), a texture's
// or a sampler object's parameters (SamplesAsIncompleteTexture -
// the params aggregate), an upload that defines a level (content
// aggregate), the default texture's image appearing (bind
// generation, TextureObject.cpp); AND the program in use -
// glUseProgram, a relink, glUniform1i on a sampler uniform (which
// unit a uniform's TYPE resolves) - F-1
// shutter: content x params x bind generation x opaqueUnits
// bit 13 bind_sampler_states: per unit the sampler CSO handle
// setters: glBindSampler (bind generation, c0d), glSamplerParameter* /
// glTexParameter* (params aggregate + sampling resolution),
// glDeleteSamplers (bind generation)
// shutter: params x sampling resolution x bind generation
// bit 14 set_shader_images: per unit {Res, InternalFormat, Layer, Level,
// Layered, Access} over the program's image-unit window
// setters: glBindImageTexture (bind generation, SD-0), a texture's
// content/params, glUniform1i on an image uniform (image unit
// version); AND the program in use - glUseProgram, a relink -
// F-2
// shutter: content x params x bind generation x programImages
// (lifetime id x link version x image unit version)
// ===========================================================================
// ---- 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);
// Bit 11, WIDENED AT P4a AND THIS IS A REQUIREMENT RATHER THAN AN OPTION. The
// shutter observed the DRAW binding slot only, so glBindFramebuffer(
// GL_READ_FRAMEBUFFER, ...) moved nothing at all - which was harmless while
// nothing was emitted for the bit and is an UNDER-FIRE the moment P4a emits
// set_framebuffer_state per bound target (D-C2): the read record would never be
// sent and the server's ReadSurface would stay the previous framebuffer's. Over-
// firing costs one extra push; under-firing renders stale, and this file's own
// rule is that under-firing is the dangerous direction.
//
// A STORAGE REDEFINITION OF AN ATTACHED OBJECT MOVES THIS SHUTTER (P4a fable seam
// F-3), and the sentence that stood here - "a renderbuffer respecify is still
// invisible here, and deliberately so ... closed by emitting resource_respecify
// straight from the storage entry point" - was true of the RESOURCE record only.
// set_framebuffer_state inlines each attachment's InternalFormat, TextureTarget,
// extent, Samples and Complete (D-C1: "so the four cross-object masks fall out at
// push time with no lookup"), so `glTexImage2D(tex, RGB8); attach; draw;
// glTexImage2D(tex, RGBA8); draw` left the FRAMEBUFFER record saying RGB8 while the
// resource record said RGBA8, and the handle arm answered its alpha-widening,
// snorm-clamp and integer masks from the stale copy where the legacy arm re-read
// the frontend at the same re-sync - a proven arm divergence on a public-GL
// sequence. The fix is at the SETTER, not here: TextureObjectBase::PipePublish
// Descriptor and RenderbufferObject::PipePublishDescriptor - the one funnel every
// storage-defining entry point of either object takes, push-only - bump the
// attachment aggregate this shutter already reads. No counter is added to either
// object (G1), nothing widens this shutter onto the texture-content aggregate (which
// would fire the 304-byte record build on every glTexSubImage2D), and a storage
// definition of an UNATTACHED object over-fires it exactly once at load time.
//
// AND A TRAP THE NEXT NARROWING WOULD WALK INTO, recorded here because it is
// invisible from the shutter: FramebufferObject::SetDrawBuffer versions the VALUE
// being written rather than the index being written TO - it calls
// BumpAttachmentVersion(buffer). The object version and the aggregate still move,
// so THIS shutter is safe; a narrower one built on m_attachmentVersions would not
// be, and P4a must not build one.
now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter(
MGPipeMixShutter(
ctx.GetAnyFramebufferAttachmentGeneration(),
m_framebufferBind.Observe(
ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion())),
m_readFramebufferBind.Observe(
ctx.GetFramebufferBindingSlot(FramebufferTarget::Read).GetVersion()));
// Bit 12 reads FOUR things (F-1): the two texture aggregates, the bind generation
// and the program input computed above. The params aggregate is here because
// SamplerEmit.h drops a unit's view to null when SamplesAsIncompleteTexture says so,
// and that predicate reads the effective sampler's filters - a glTexParameteri(
// MIN_FILTER) that completes a texture fired bit 13 and not this one, so the entry
// stayed null. The program input is here because the set is resolved FOR THE
// PROGRAM IN USE, and a glUseProgram alone moved nothing this shutter read.
now[Index(MGPipeDirty::NewSamplerViews)] = MGPipeMixShutter(
MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), ctx.GetTextureBindGeneration()),
opaqueUnits);
// Bit 13, WIDENED AT P4a FOR BIT 11's REASON and found the same way. glBindSampler
// moves NEITHER half of what this used to read: GL_Sampler.cpp's BindSampler_State
// goes through NoteTextureUnitTouched and TextureUnit::SetSamplerObject, and both
// of those bump the TEXTURE BIND generation - bit 12's. The only two writers of
// BumpSamplingResolutionGeneration are PARAMETER changes (SamplerObject.cpp,
// TextureObject.cpp). So `glBindSampler(3, a); draw; glBindSampler(3, b); draw`
// fired bit 12 twice and bit 13 not once, and the server's BoundSamplerStates[3]
// went on naming a's CSO: wrong filtering, with nothing able to see it, because
// bind_sampler_states has no pulled twin to fall back on the way the view set does.
//
// MIXING THE GENERATION IN IS THE FIX RATHER THAN A SECOND GATE ON THE EMITTER,
// because that generation is what the unit SET is derived from: a sampler bind
// changes which sampler state applies at a unit, and a texture bind changes it too
// whenever the unit carries no sampler object and the texture's BUILT-IN sampler is
// what applies. Keeping it one shutter per bit is also what keeps the per-subsystem
// A/B and the per-bit fire tallies meaning what they say - a bit gated on another
// bit's shutter measures neither. The extra fires a plain texture bind now costs
// are swallowed by the emitter's own set-hash suppressor, which MGPipeTypes.h makes
// mandatory for every kVarTail set for this exact traffic.
now[Index(MGPipeDirty::NewSamplers)] = MGPipeMixShutter(
MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration()),
ctx.GetTextureBindGeneration());
now[Index(MGPipeDirty::NewShaderImages)] = MGPipeMixShutter(
MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages),
ctx.GetTextureBindGeneration());
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_readFramebufferBind.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;
// P4a: the READ framebuffer binding slot's version, its own counter for the same
// reason the draw one exists. Two counters rather than one over both slots: a single
// widened counter fed two independent Uint16s reads a decrease as a wrap on every
// alternation and would add 65536 per switch, which costs nothing in correctness
// (over-firing) but makes the high word meaningless.
MGPipeWidenedCounter m_readFramebufferBind;
// 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() {
// NEVER DESTROYED, for MGPipeSlots()' reason (MG_Impl/Pipe/SlotAllocator.cpp). The
// rule is stated over the SET of MGPipe process singletons rather than over the two
// that a frontend destructor reaches today: which of them a destructor reaches is a
// property of the emitters, and the emitters change (C-1 added a second reaching
// path in one commit). One allocation per process, no destructor to lose - this type
// has none - and nothing can then answer a late call out of freed storage.
static MGPipeTracker* tracker = new MGPipeTracker();
return *tracker;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
+444
View File
@@ -0,0 +1,444 @@
// 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() {
// NEVER DESTROYED, for MGPipeSlots()' reason (MG_Impl/Pipe/SlotAllocator.cpp), and
// this one is not hypothetical: C-1 put this emitter DIRECTLY on ~VertexArrayObject's
// path - MGPipeEmitVertexElementsDestroyAndFree asks RecordIsPublished(handle) and
// then NoteRecordDestroyed(handle), which read and WRITE m_latch. A destroyed
// emitter answers out of a freed Vector and the write grows it, i.e. an operator
// new + memcpy + operator delete on an already-freed block.
static MGPipeVertexInputEmitter* emitter = new MGPipeVertexInputEmitter();
return *emitter;
}
} // namespace MobileGL::MG_Pipe
#endif // MOBILEGL_PIPE_PUSH
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.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 "P4aFinalFixPeek.h"
#if !defined(__ANDROID__)
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/MGPipeTypes.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_Util/Metrics/PipeStats.h>
#define MGITEST_P4A_FINALFIX_PEEK_LIVE 1
#endif
#endif
namespace MGITest {
#if defined(MGITEST_P4A_FINALFIX_PEEK_LIVE)
namespace {
namespace MGP = MobileGL::MG_Pipe;
} // namespace
bool PeekPipeTextureResourceRecord(unsigned glTextureName, PipeTextureResourceRecordPeek* out) {
if (out == nullptr) return false;
const MGP::MGPipeApplierState& applier = MGP::MGPipeApplier();
// Slot 0 is the reserved null slot; the walk is the same shape PipeApplyPeek.cpp's
// params reading takes. A GL name is never an identity on the wire, which is exactly
// why it is the right key for a harness that starts from the application's view.
for (MobileGL::SizeT slot = 1; slot < applier.TextureResources.size(); ++slot) {
const MGP::MGPipeResourceRecord& record = applier.TextureResources[slot];
if (!record.Live) continue;
if (record.Desc.GlNameForDiag != static_cast<MobileGL::Uint32>(glTextureName)) continue;
out->Slot = static_cast<unsigned>(slot);
out->Gen = static_cast<unsigned>(record.Gen);
out->Serial = static_cast<unsigned long long>(record.Serial);
out->BindMask = static_cast<unsigned>(record.Desc.BindMask);
out->ImageBindableHint = static_cast<unsigned>(record.Desc.ImageBindableHint);
out->Levels = static_cast<unsigned>(record.Desc.Levels);
out->PendingUploads = static_cast<unsigned>(record.PendingUploads.size());
return true;
}
return false;
}
bool PeekPipeStatsTextureRemintPulls(unsigned long long* out) {
if (out == nullptr) return false;
namespace Stats = MobileGL::MG_Util::PipeStats;
if (!Stats::Enabled()) Stats::SetEnabledForTesting(true);
*out = static_cast<unsigned long long>(Stats::TotalCalls(Stats::CallClass::TextureRemintPulls));
return true;
}
bool PeekPipeStatsTextureUploadEmissions(unsigned long long* out) {
if (out == nullptr) return false;
namespace Stats = MobileGL::MG_Util::PipeStats;
if (!Stats::Enabled()) Stats::SetEnabledForTesting(true);
*out = static_cast<unsigned long long>(Stats::TotalCalls(Stats::CallClass::TextureUploadEmissions));
return true;
}
#else
bool PeekPipeTextureResourceRecord(unsigned, PipeTextureResourceRecordPeek*) { return false; }
bool PeekPipeStatsTextureRemintPulls(unsigned long long*) { return false; }
bool PeekPipeStatsTextureUploadEmissions(unsigned long long*) { return false; }
#endif
} // namespace MGITest
@@ -0,0 +1,41 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.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
//
// The white-box readings P4aFinalFixScenario.cpp takes, in a translation unit of their own for
// P4aSeamPeek.h's reason: a scenario TU includes the GL prototype headers and cannot include
// MG_Pipe/PipeApply.h or the Espryt managers beside them, and PipeApplyPeek.cpp is the gates
// package's file. Every entry point answers false where the reading cannot be taken (a pull
// build, Android, or an applier that holds no record for the name), and a false teaches the
// caller nothing - the case declines that half by name and keeps its public-GL verdict.
#pragma once
namespace MGITest {
// The applier's resource record for a texture, found by its GL name (GlNameForDiag - a
// diagnostics-only field, which is exactly what a test harness is).
struct PipeTextureResourceRecordPeek {
unsigned Slot;
unsigned Gen;
unsigned long long Serial;
unsigned BindMask;
unsigned ImageBindableHint;
unsigned Levels;
unsigned PendingUploads;
};
bool PeekPipeTextureResourceRecord(unsigned glTextureName, PipeTextureResourceRecordPeek* out);
// The process-wide texture-remint pull count (PipeStats "tex-remint-pulls", `trp=` on the
// summary line; ROADMAP open question 2). Arms the PipeStats counters for this process on
// the first call, which is what lets a case read the number without a stats-enabled lane.
bool PeekPipeStatsTextureRemintPulls(unsigned long long* out);
// Espryt's count of texture uploads it actually issued (PipeStats "tex-upload-emissions"):
// what tells a CONSUMED pending upload apart from a DROPPED one, since the record's set is
// empty either way. Arms the counters the same way.
bool PeekPipeStatsTextureUploadEmissions(unsigned long long* out);
} // namespace MGITest
@@ -0,0 +1,107 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.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 "P4aSeamPeek.h"
#if !defined(__ANDROID__)
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#include <MG_Backend/DirectGLES/Managers.h>
#include <MG_Backend/DirectGLES/DirectGLES.h>
#define MGITEST_P4A_SEAM_PEEK_LIVE 1
#endif
#endif
namespace MGITest {
#if defined(MGITEST_P4A_SEAM_PEEK_LIVE)
namespace {
namespace MGP = MobileGL::MG_Pipe;
namespace MGB = MobileGL::MG_Backend::DirectGLES;
// "Is Espryt the backend running" - the same test PipeApplyPeek.cpp makes through a twin:
// on Magma no ES entry point was ever resolved and every member of g_GLESFuncs is null.
// It is asked BEFORE SamplerSubsystemEnabled(), which is Espryt's own latch and must not
// be resolved on a process whose backend is not Espryt.
bool EsprytIsRunning() { return MGB::g_GLESFuncs.glBindSampler != nullptr; }
} // namespace
bool PeekEsprytSamplerHandleArmIsLive(bool* outLive) {
if (outLive == nullptr) return false;
if (!EsprytIsRunning()) return false;
*outLive = MGB::SamplerSubsystemEnabled();
return true;
}
bool PeekEsprytFramebufferHandleArmIsLive(bool* outLive) {
if (outLive == nullptr) return false;
if (!EsprytIsRunning()) return false;
*outLive = MGB::FramebufferSubsystemEnabled();
return true;
}
bool PeekPipeShaderImageWindow(PipeShaderImageWindowPeek* out) {
if (out == nullptr) return false;
const MGP::MGPipeApplierState& applier = MGP::MGPipeApplier();
out->Start = static_cast<unsigned>(applier.ShaderImageStart);
out->Count = static_cast<unsigned>(applier.ShaderImageCount);
out->Serial = static_cast<unsigned long long>(applier.ShaderImagesSerial);
return true;
}
bool PeekEsprytUnitSampler(unsigned unit, unsigned glSamplerName, EsprytUnitSamplerPeek* out) {
if (out == nullptr) return false;
if (!EsprytIsRunning()) return false;
if (!MobileGL::MG_State::pGLContext) return false;
const MGP::MGPipeApplierState& applier = MGP::MGPipeApplier();
if (unit >= applier.BoundSamplerStates.size() || unit >= MGB::SamplerImpl::g_boundSamplersCache.size()) {
return false;
}
*out = EsprytUnitSamplerPeek{};
// Espryt's own binding shadow: every glBindSampler this backend issues routes through it
// (BackendSamplerObject::Bind / UnbindSampler), so it IS what the driver holds.
if (MGB::SamplerImpl::BackendSamplerObject* const bound = MGB::SamplerImpl::g_boundSamplersCache[unit]) {
out->BoundSamplerId = static_cast<unsigned>(bound->GetBackendSamplerId());
}
const MGP::MGPipeHandle cso = applier.BoundSamplerStates[unit];
out->CsoHandleSlot = static_cast<unsigned>(cso.Slot);
out->CsoHandleGen = static_cast<unsigned>(cso.Gen);
out->UnitInsideWindow = unit >= applier.SamplerStateStart &&
unit - applier.SamplerStateStart < applier.SamplerStateCount;
// The twin AT THE CSO HANDLE, asked of the same table Espryt asks (FindByHandle): a null
// here with a live handle is the F-4 shape - a content-addressed handle looked up in a
// table that only ever held identity-minted slots.
if (!MGP::MGPipeHandleIsNull(cso)) {
if (auto* const slot = MGB::SamplerImpl::g_backendSamplerObjects.FindByHandle(cso); slot && *slot) {
out->CsoTwinSamplerId = static_cast<unsigned>((*slot)->GetBackendSamplerId());
}
}
// And the twin keyed on the frontend OBJECT, which is what the pre-handle program pass
// used to mint and bind, so a scenario can say which of the two the driver holds.
const auto& object = MobileGL::MG_State::pGLContext->GetSamplerObject(
static_cast<MobileGL::Uint>(glSamplerName));
if (object) {
if (auto* const slot = MGB::SamplerImpl::g_backendSamplerObjects.Find(object.get()); slot && *slot) {
out->IdentityTwinSamplerId = static_cast<unsigned>((*slot)->GetBackendSamplerId());
}
}
return true;
}
#else
bool PeekEsprytSamplerHandleArmIsLive(bool*) { return false; }
bool PeekEsprytFramebufferHandleArmIsLive(bool*) { return false; }
bool PeekPipeShaderImageWindow(PipeShaderImageWindowPeek*) { return false; }
bool PeekEsprytUnitSampler(unsigned, unsigned, EsprytUnitSamplerPeek*) { return false; }
#endif
} // namespace MGITest
@@ -0,0 +1,78 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.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
//
// The three readings P4aSeamAuditScenario.cpp takes from the inside, for the two seams the fable
// seam audit proved that PUBLIC GL CANNOT SEE: F-4 (the record arm's sampler bind is a permanent
// no-op, hidden by the pre-handle program pass binding the same values) and F-2 / SD-4 (the
// shader-image window does not follow a program switch, hidden by the server's window/high-water
// union taking the pre-handle bind for the units outside it). Both are correct pictures over a
// permanent silent fallback, which is precisely the class ROADMAP.md:20 says a gate has to be
// able to make red - and the only place the difference exists is inside.
//
// A SEPARATE TRANSLATION UNIT for PipeApplyPeek.h's reason, verbatim: this file includes
// Espryt's own Managers.h, which may not meet a scenario's GL headers in one file. It is NOT
// PipeApplyPeek.cpp because that file is package F's (gates v3) and this round may not edit it.
//
// EVERY ENTRY POINT RETURNS false, TOUCHING NOTHING, WHERE IT CANNOT LOOK - a pull build, Android,
// a backend that is not Espryt - and a caller that gets false has learned NOTHING: "could not
// look" is not "was bound". The scenario declines the reading BY NAME and keeps its public-GL
// half, which is the shape TextureParamsWithoutASamplerViewScenario.cpp argues for.
#pragma once
namespace MGITest {
// ---- is Espryt's sampler family on its HANDLE arm in this process? -------------------
//
// The gate for every other reading here. True only on DirectGLES, in a push build, with
// Espryt's own resolver answering "handle" for kMGPipeSubsystemSamplers (bit 11 set and its
// dependency satisfied) - i.e. exactly when bind_sampler_states / set_shader_images are
// consumed, so a white-box assertion about them can be red for its own reason and for no
// other. Written only on true.
bool PeekEsprytSamplerHandleArmIsLive(bool* outLive);
// The same question for the FRAMEBUFFER family (bit 9): true when Espryt consumes
// set_framebuffer_state in this process. The renderbuffer half of the F-3 case asserts only
// there - on the pre-handle arm a renderbuffer re-storaged while attached moves nothing the
// FBO memo reads (D-D2's documented hole, pre-P4a code), and the record is what closes it.
bool PeekEsprytFramebufferHandleArmIsLive(bool* outLive);
// ---- the applier's shader-image window, as last received ------------------------------
//
// MGPipeApplierState::ShaderImageStart / ShaderImageCount / ShaderImagesSerial. Count is
// "how many units set_shader_images last described" - 0 means the set has NEVER arrived
// (MGPipeApplierReset advances the serial whether or not anything was emitted, so the serial
// is not that test). Push build only.
struct PipeShaderImageWindowPeek {
unsigned Start;
unsigned Count;
unsigned long long Serial;
};
bool PeekPipeShaderImageWindow(PipeShaderImageWindowPeek* out);
// ---- which driver sampler a texture unit is bound to, and whose twin it is -------------
//
// For F-4. `BoundSamplerId` is the ES sampler name Espryt's own binding shadow says unit
// `unit` carries (0 = none). `CsoHandleSlot/Gen` is bind_sampler_states' handle for the unit,
// `CsoTwinSamplerId` the ES name of the twin Espryt holds AT THAT HANDLE (0 = no twin at the
// content-addressed slot - the F-4 shape), and `IdentityTwinSamplerId` the ES name of a twin
// keyed on the frontend SamplerObject named `glSamplerName` (0 = none). On a correct handle
// arm the unit's driver sampler IS the CSO twin. Push build, DirectGLES only.
struct EsprytUnitSamplerPeek {
unsigned BoundSamplerId;
unsigned CsoHandleSlot;
unsigned CsoHandleGen;
bool UnitInsideWindow;
unsigned CsoTwinSamplerId;
unsigned IdentityTwinSamplerId;
};
bool PeekEsprytUnitSampler(unsigned unit, unsigned glSamplerName, EsprytUnitSamplerPeek* out);
} // namespace MGITest
@@ -0,0 +1,188 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.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 "PipeApplyPeek.h"
#if !defined(__ANDROID__)
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <MG_Pipe/PipeApply.h>
#include <MG_Pipe/MGPipeTypes.h>
#include <MG_State/GLState/Core.h>
#include <MG_Util/Converters/MGToGL/TextureEnumConverter.h>
#include <MG_Backend/DirectGLES/Managers.h>
#include <MG_Backend/DirectGLES/DirectGLES.h>
#define MGITEST_PIPE_APPLY_PEEK_LIVE 1
#endif
#endif
namespace MGITest {
#if defined(MGITEST_PIPE_APPLY_PEEK_LIVE)
namespace {
namespace MGP = MobileGL::MG_Pipe;
namespace MGB = MobileGL::MG_Backend::DirectGLES;
// The frontend texture object a GL name denotes in the CURRENT context, or null. This is
// a LOOKUP KEY and nothing else: every value this file reports comes from the applier or
// from Espryt, never from the object found here. (Reading the frontend's own parameter
// state would answer the question the scenario is asking with the input to it.)
MobileGL::MG_State::GLState::ITextureObject* FrontendTexture(unsigned glTextureName) {
if (!MobileGL::MG_State::pGLContext) return nullptr;
const auto& object = MobileGL::MG_State::pGLContext->GetTextureObject(
static_cast<MobileGL::Uint>(glTextureName));
return object ? object.get() : nullptr;
}
// Espryt's twin for that texture, or null - which is also this file's "is Espryt even the
// backend running" answer. On Magma no Espryt twin was ever built, so every entry point
// below stops here rather than reaching for g_GLESFuncs, whose members are null there.
MGB::TextureImpl::BackendTextureObject* EsprytTwin(unsigned glTextureName) {
MobileGL::MG_State::GLState::ITextureObject* const object = FrontendTexture(glTextureName);
if (object == nullptr) return nullptr;
auto* const found = MGB::TextureImpl::g_backendTextureObjects.Find(object);
if (found == nullptr || !*found) return nullptr;
return found->get();
}
int SwizzleToGLEnum(MobileGL::Uint8 encoded) {
return static_cast<int>(MobileGL::MG_Util::ConvertTextureSwizzleParamToGLEnum(
static_cast<MobileGL::TextureSwizzleParam>(encoded)));
}
// MGPipeTypes.h owns the two numbers and says why depth is 0 (a zeroed record must decode
// to what an untouched texture already has). This is that decode, and nothing else in
// this module may open-code it.
int DepthStencilModeToGLEnum(MobileGL::Uint8 encoded) {
return encoded == MGP::kMGPipeDepthStencilModeStencil ? GL_STENCIL_INDEX
: GL_DEPTH_COMPONENT;
}
// The GL_TEXTURE_BINDING_* query for a target, or 0 where this file has no answer. A
// guess would be worse than a refusal: the binding is what gets RESTORED, so a wrong
// pname would leave the driver bound to this test's texture.
int BindingQueryFor(unsigned glTarget) {
switch (glTarget) {
case GL_TEXTURE_2D: return GL_TEXTURE_BINDING_2D;
default: return 0;
}
}
} // namespace
bool PeekPipeTextureParamsRecord(unsigned glTextureName, PipeTextureParamsRecordPeek* out) {
if (out == nullptr) return false;
const MGP::MGPipeApplierState& applier = MGP::MGPipeApplier();
// Slot 0 is the reserved null handle and is never live (MGPipeHandles.h), so the scan
// starts at 1 and a match at 0 is impossible rather than merely unlikely.
for (MobileGL::SizeT slot = 1; slot < applier.TextureResources.size(); ++slot) {
const MGP::MGPipeResourceRecord& record = applier.TextureResources[slot];
if (!record.Live) continue;
if (record.Desc.GlNameForDiag != static_cast<MobileGL::Uint32>(glTextureName)) continue;
out->Slot = static_cast<unsigned>(slot);
out->Gen = static_cast<unsigned>(record.Gen);
out->ParamsSerial = static_cast<unsigned long long>(record.ParamsSerial);
for (int channel = 0; channel < 4; ++channel) {
out->Swizzle[channel] = SwizzleToGLEnum(record.Params.Swizzle[channel]);
}
out->DepthStencilMode = DepthStencilModeToGLEnum(record.Params.DepthStencilMode);
return true;
}
return false;
}
bool PeekEsprytAppliedTextureParams(unsigned glTextureName, unsigned glTarget,
EsprytAppliedTextureParamsPeek* out) {
if (out == nullptr) return false;
const int bindingQuery = BindingQueryFor(glTarget);
if (bindingQuery == 0) return false;
MGB::TextureImpl::BackendTextureObject* const twin = EsprytTwin(glTextureName);
if (twin == nullptr) return false;
const MobileGL::Uint backendId = twin->GetBackendTextureId();
if (backendId == 0) return false;
if (MGB::g_GLESFuncs.glGetTexParameteriv == nullptr ||
MGB::g_GLESFuncs.glBindTexture == nullptr || MGB::g_GLESFuncs.glGetIntegerv == nullptr ||
MGB::g_GLESFuncs.glGetError == nullptr) {
return false;
}
// SAVE / QUERY / RESTORE ON THE UNIT THAT IS ALREADY ACTIVE. No glActiveTexture, so the
// only driver state this touches is one unit's binding, and it is put back byte for byte
// - which is what keeps Espryt's own g_boundTexturesCache true rather than merely
// consistent. (Binding through the twin's own Bind() would update that shadow and would
// therefore CHANGE what the scenario measures next; this does not.)
GLint previousBinding = 0;
MGB::g_GLESFuncs.glGetIntegerv(static_cast<GLenum>(bindingQuery), &previousBinding);
MGB::g_GLESFuncs.glBindTexture(static_cast<GLenum>(glTarget), backendId);
out->BackendTextureId = static_cast<unsigned>(backendId);
static const GLenum kSwizzlePnames[4] = {GL_TEXTURE_SWIZZLE_R, GL_TEXTURE_SWIZZLE_G,
GL_TEXTURE_SWIZZLE_B, GL_TEXTURE_SWIZZLE_A};
for (int channel = 0; channel < 4; ++channel) {
GLint value = 0;
MGB::g_GLESFuncs.glGetTexParameteriv(static_cast<GLenum>(glTarget),
kSwizzlePnames[channel], &value);
out->Swizzle[channel] = static_cast<int>(value);
}
// The depth/stencil aspect mode is ES 3.1 and is INVALID_ENUM on a driver without it, so
// it is asked for last and its own error decides whether the answer is usable. The queue
// is drained first because a stale error from anywhere else would be indistinguishable
// from this call's - Espryt drains it the same way at every one of its own sync sites
// (DebugImpl::ErrorLopper), and this module's own GL errors are read from the FRONTEND
// state (ScenarioTest::FirstGLError), which none of this touches.
while (MGB::g_GLESFuncs.glGetError() != GL_NO_ERROR) {
}
GLint mode = 0;
MGB::g_GLESFuncs.glGetTexParameteriv(static_cast<GLenum>(glTarget),
GL_DEPTH_STENCIL_TEXTURE_MODE, &mode);
out->DepthStencilModeIsReadable = MGB::g_GLESFuncs.glGetError() == GL_NO_ERROR;
out->DepthStencilMode = static_cast<int>(mode);
MGB::g_GLESFuncs.glBindTexture(static_cast<GLenum>(glTarget),
static_cast<GLuint>(previousBinding));
while (MGB::g_GLESFuncs.glGetError() != GL_NO_ERROR) {
}
return true;
}
bool PeekEsprytHasSamplerViewForTexture(unsigned glTextureName, bool* outExists) {
if (outExists == nullptr) return false;
MobileGL::MG_State::GLState::ITextureObject* const object = FrontendTexture(glTextureName);
if (object == nullptr) return false;
// Espryt must be the backend running, or "no view" would be true of every texture on
// every other backend and the assertion would be vacuous where it is loudest.
if (EsprytTwin(glTextureName) == nullptr) return false;
// HandleOfSamplerViewForTexture is the monolith glue that derives the view's handle from
// the TEXTURE's lifetime id (D-F2: one view per ITextureObject), so this asks Espryt's
// own table the same way Espryt asks it - it does not consult the applier record's
// ViewCso, which is the client's statement about the same fact and would make one side
// of the seam vouch for the other.
const MGP::MGPipeHandle view = MGB::SamplerViewImpl::HandleOfSamplerViewForTexture(object);
if (MGP::MGPipeHandleIsNull(view)) {
*outExists = false;
return true;
}
*outExists = MGB::SamplerViewImpl::FindSamplerViewForHandle(view) != nullptr;
return true;
}
bool PeekPipeApplierRefusedNoConsumer(unsigned long long* outCount) {
if (outCount == nullptr) return false;
*outCount = static_cast<unsigned long long>(MGP::MGPipeApplier().RefusedNoConsumer);
return true;
}
#else
bool PeekPipeTextureParamsRecord(unsigned, PipeTextureParamsRecordPeek*) { return false; }
bool PeekEsprytAppliedTextureParams(unsigned, unsigned, EsprytAppliedTextureParamsPeek*) {
return false;
}
bool PeekEsprytHasSamplerViewForTexture(unsigned, bool*) { return false; }
bool PeekPipeApplierRefusedNoConsumer(unsigned long long*) { return false; }
#endif
} // namespace MGITest
@@ -0,0 +1,110 @@
// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.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
//
// The APPLIER's texture-parameter record, ESPRYT's applied value for the same texture, and
// whether that texture has a sampler view yet. Three readings taken from a scenario, for gate
// G9's WHITE-BOX half.
//
// WHY A WHITE-BOX HALF EXISTS AT ALL (ID-19, brief section F, gates review R1). G9's public-GL
// cases in TextureParamsWithoutASamplerViewScenario.cpp catch "the parameter never reached the
// driver". They CANNOT catch "the parameter reached the driver LATE", because a texture
// parameter's only public-GL observable is a SAMPLE and the sample is itself what repairs an
// unsynced parameter: it puts the texture on the unit list, and that walk pushes the parameters
// for anything whose params serial moved. A backend that deferred every attachment-only
// texture's parameters to the first sampler view would be green on all four of those cases,
// forever, on every tree. The distinction only exists on the inside, so the reading has to be
// taken there - while the texture is still attachment-only, before any sample.
//
// A SEPARATE TRANSLATION UNIT for PipeSlotPeek.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. This one goes further than PipeSlotPeek and includes Espryt's own
// Managers.h, which is exactly why it may not be anywhere near a scenario's GL headers.
//
// EVERY ENTRY POINT RETURNS false, TOUCHING NOTHING, WHERE IT CANNOT LOOK, and a caller that
// gets false has learned NOTHING - "could not look" is not "was applied". Out of reach means:
// a PULL build (there is no applier: it is `#if MOBILEGL_PIPE_PUSH`); Android, where this module
// links the shipping libMobileGL.so built -fvisibility=hidden and no internal symbol resolves;
// a backend that is not DirectGLES (Espryt is the subject; Magma answers the same GL question
// through P7's own paths); and, for the record peek, a mask whose texture-resource bit is off,
// where no record exists to find because nothing was ever emitted.
#pragma once
namespace MGITest {
// ---- the applier's set_texture_params record for one GL texture name ------------------
//
// ADDRESSED BY GL NAME, and the search key is MGPResourceDesc::GlNameForDiag. That field is
// diagnostics-only by contract - never an identity, never a memo key (MGPipeTypes.h) - and
// this is a diagnostic: a test harness looking for the record a named GL object produced.
// The alternative would be to ask the CLIENT emitter for the texture's handle, and the
// review is explicit that this probe must arm on package D's applier/backend state and not
// on the emitter markers B and C set: they are different questions, and a shared marker
// would re-create the shape review F-M5 was raised about.
struct PipeTextureParamsRecordPeek {
// The handle the record sits at, so a caller can print it.
unsigned Slot;
unsigned Gen;
// set_texture_params' own serial. 0 means the record exists (the resource was created)
// but NO set_texture_params has ever been applied to it - which is a different finding
// from "no record", and the two must not be merged.
unsigned long long ParamsSerial;
// MGPTextureParams::Swizzle[4], translated to the GL enums the application passed to
// glTextureParameteri (GL_ZERO / GL_ONE / GL_RED / GL_GREEN / GL_BLUE / GL_ALPHA), so
// the scenario compares what it set against what the record carries in ONE vocabulary
// and neither side has to know the other's encoding.
int Swizzle[4];
// MGPTextureParams::DepthStencilMode, translated the same way: GL_DEPTH_COMPONENT or
// GL_STENCIL_INDEX.
int DepthStencilMode;
};
bool PeekPipeTextureParamsRecord(unsigned glTextureName, PipeTextureParamsRecordPeek* out);
// ---- Espryt's APPLIED value for the same texture --------------------------------------
//
// Read from the DRIVER, through the twin's own ES name, because "applied" means the driver
// was told - the same thing package D's white-box unit probe asserts against its mocked
// driver (esprytobj-v2 (9)). The current binding on the ACTIVE unit is saved and restored
// around the query and no unit is switched, so Espryt's binding shadow still describes
// reality afterwards: nothing is perturbed for it to be stale about.
//
// `glTarget` is the texture's GL target (only GL_TEXTURE_2D is supported today; any other
// target returns false rather than guessing a binding query).
struct EsprytAppliedTextureParamsPeek {
// The driver name Espryt minted for this texture, for the caller's message.
unsigned BackendTextureId;
int Swizzle[4];
int DepthStencilMode;
// False when the driver rejected the depth/stencil query - a non-depth texture, or an ES
// level without GL_DEPTH_STENCIL_TEXTURE_MODE. The swizzle half is still valid.
bool DepthStencilModeIsReadable;
};
bool PeekEsprytAppliedTextureParams(unsigned glTextureName, unsigned glTarget,
EsprytAppliedTextureParamsPeek* out);
// ---- and the claim that makes the two above mean anything ------------------------------
//
// Whether Espryt holds a SAMPLER VIEW twin for this texture. This is the assertion the
// public-GL cases cannot make, because making it there would create the view. `*outExists`
// is written only on true.
bool PeekEsprytHasSamplerViewForTexture(unsigned glTextureName, bool* outExists);
// ---- c0f's belt, for the ObjectSubsystemControl arms -----------------------------------
//
// MGPipeApplierState::RefusedNoConsumer: the number of P4a-family entry points that were
// refused because no backend had registered MGPipeResourceOps. On a backend WITH a consumer
// it must never move; on one without (Magma, ID-39/ID-40) the client's own gate is supposed
// to stop the emission before the belt is reached, so it must never move there either. A
// non-zero delta says the gate and the belt disagreed, which is the whole point of having
// both. Reset by MGPipeApplierReset, so a caller reads it as a DELTA and treats a value that
// went DOWN as "the applier was reset, count everything since as `after`".
bool PeekPipeApplierRefusedNoConsumer(unsigned long long* outCount);
} // namespace MGITest
@@ -0,0 +1,82 @@
// 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 {
// One arm per member, and NO `default:` on purpose: adding a PipeSlotKind without
// deciding which MGPipeKind it names is a compiler warning here (-Wswitch) rather than
// a row that silently counts VertexElementsCso and reports "did not leak" about a kind
// it never looked at. The trailing return is the unreachable one the compiler needs.
MobileGL::MG_Pipe::MGPipeKind Translate(PipeSlotKind kind) {
switch (kind) {
case PipeSlotKind::Buffer: return MobileGL::MG_Pipe::MGPipeKind::Buffer;
case PipeSlotKind::VertexElementsCso:
return MobileGL::MG_Pipe::MGPipeKind::VertexElementsCso;
case PipeSlotKind::Texture: return MobileGL::MG_Pipe::MGPipeKind::Texture;
case PipeSlotKind::Renderbuffer: return MobileGL::MG_Pipe::MGPipeKind::Renderbuffer;
case PipeSlotKind::Framebuffer: return MobileGL::MG_Pipe::MGPipeKind::Framebuffer;
case PipeSlotKind::SamplerCso: return MobileGL::MG_Pipe::MGPipeKind::SamplerCso;
case PipeSlotKind::SamplerViewCso:
return MobileGL::MG_Pipe::MGPipeKind::SamplerViewCso;
case PipeSlotKind::ShaderCso: return MobileGL::MG_Pipe::MGPipeKind::ShaderCso;
}
return MobileGL::MG_Pipe::MGPipeKind::None;
}
} // 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;
// The ORDINARY space only, for every kind including ShaderCso (contract-v2.md 4.3).
*outHighWater = static_cast<unsigned>(MobileGL::MG_Pipe::MGPipeSlots().HighWater(Translate(kind)));
return true;
}
bool PeekPipeCompositeSlotLiveCount(unsigned* outLive) {
if (outLive == nullptr) return false;
*outLive = static_cast<unsigned>(MobileGL::MG_Pipe::MGPipeSlots().CompositeLiveCount());
return true;
}
bool PeekPipeCompositeSlotHighWater(unsigned* outHighWater) {
if (outHighWater == nullptr) return false;
*outHighWater = static_cast<unsigned>(MobileGL::MG_Pipe::MGPipeSlots().CompositeHighWater());
return true;
}
bool PeekPipeCompositeSlotBandBase(unsigned* outBandBase) {
if (outBandBase == nullptr) return false;
*outBandBase = static_cast<unsigned>(MobileGL::MG_Pipe::kMGPipeShaderCsoCompositeSlotBase);
return true;
}
#else
bool PeekPipeSlotLiveCount(PipeSlotKind, unsigned*) { return false; }
bool PeekPipeSlotHighWater(PipeSlotKind, unsigned*) { return false; }
bool PeekPipeCompositeSlotLiveCount(unsigned*) { return false; }
bool PeekPipeCompositeSlotHighWater(unsigned*) { return false; }
bool PeekPipeCompositeSlotBandBase(unsigned*) { return false; }
#endif
} // namespace MGITest
@@ -0,0 +1,101 @@
// 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,
// P4a's six (G8b). Every one of them is a kind the CLIENT mints and the client alone
// frees (BRIEF-P4A.md D-I1: one death helper per kind, called from the frontend
// object's own destructor, whatever backend is running), so every one of them can leak
// the P3a C-1 way - and the leak is invisible in pixels, in GL names and in
// glGetError, exactly as the VertexElementsCso one was.
Texture,
Renderbuffer,
// Framebuffer has a HANDLE but no wire lifetime (D-I2): no create_*, no destroy row in
// the catalogue, and its death helper does the notice and the free and emits nothing.
// That makes the allocator the ONLY observable of its lifetime, so this row matters
// more here than the others rather than less.
Framebuffer,
SamplerCso,
SamplerViewCso,
// ShaderCso covers BOTH the ordinary program slots and the program-pipeline COMPOSITES
// minted out of the reserved high band (MGPipeHandles.h:86-107, D-H7). One kind, because
// that is what the allocator has: the band is a second dense table inside the same kind
// and LiveCount counts both.
//
// THE TWO SPACES' HIGH-WATER MARKS ARE NOT ONE NUMBER, and the correction matters here
// more than anywhere else. c0b split them (contract-v2.md 4.3): HighWater(ShaderCso) is
// now the ORDINARY space only and the band's own mark is CompositeHighWater(), because
// a merged mark is pinned at ~983k from the first composite mint onward and every "the
// high-water mark did not move over N churn rounds" assertion about ordinary programs
// would be vacuously true for the rest of the process. The composite's leak case is a
// separate CASE and reads the BAND'S OWN counters below (PeekPipeCompositeSlot*) - a
// composite's slot has TWO independent release paths (the pipeline cache's LRU eviction
// and the composite ProgramObject's destructor), and a slot that never comes back to
// the band moves neither of the ordinary numbers.
ShaderCso,
};
// 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);
// The ShaderCso COMPOSITE BAND's own three numbers, the seventh..ninth members
// contract-v2.md 4.3 asks this header for. There is no `kind` argument because the band is
// ShaderCso's alone - AllocateComposite is the one door into it and no other kind has one.
// All three return false on the same terms as the two above, and a caller that gets false
// must SKIP.
//
// PeekPipeCompositeSlotLiveCount = MGPipeSlotAllocator::CompositeLiveCount(), the band's
// share of LiveCount(ShaderCso).
// PeekPipeCompositeSlotHighWater = CompositeHighWater() VERBATIM, i.e. one past the
// highest band slot ever handed out. It is an ABSOLUTE
// slot number and therefore starts at the band's base,
// not at zero - "no composite was ever minted" reads as
// `high water == band base`, which is what the third
// member is for. It is not returned base-relative
// because a peek whose name says HighWater and whose
// value is a delta is exactly the kind of quietly
// redefined counter this member exists to correct.
// PeekPipeCompositeSlotBandBase = kMGPipeShaderCsoCompositeSlotBase, the floor the
// other two are read against. A constant, but it
// reaches a scenario only through this header: the
// MG_Pipe headers and the GL headers are not meant to
// meet in one translation unit, which is why this
// harness exists at all.
bool PeekPipeCompositeSlotLiveCount(unsigned* outLive);
bool PeekPipeCompositeSlotHighWater(unsigned* outHighWater);
bool PeekPipeCompositeSlotBandBase(unsigned* outBandBase);
} // 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,878 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ObjectSubsystemControlScenario.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 P4a SUBSYSTEM A/B IS REAL, AND ITS DEPENDENCY REFUSALS ARE EXERCISED (gate G12).
//
// P4a migrates FOUR subsystems (D-K1, MG_Pipe/MGPipe.h):
//
// bit 9 kMGPipeSubsystemFramebuffer set_framebuffer_state
// bit 10 kMGPipeSubsystemTextureResources texture + renderbuffer resource_*, set_texture_params
// bit 11 kMGPipeSubsystemSamplers sampler CSO, sampler view, the three unit sets
// bit 12 kMGPipeSubsystemPrograms shader CSO, draw/dispatch program, global constants
//
// so the push build's default mask becomes kMGPipeSubsystemsMigratedAtP4a = 0x1fff, and P3a's
// 0x1ff survives as the control that clears exactly those four - MGPipe.h's rule that every phase's
// constant keeps meaning what it meant, so an operator's recorded mask is still readable a phase
// later. THE OFF LANE IS 0x1ff AND NOT A HAND-PICKED PATTERN, for that reason.
//
// 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. It is the P4a analogue of ResourceSubsystemControlScenario and
// deliberately its twin in shape.
//
// WHAT IT ASSERTS, per lane:
//
// on (MOBILEGL_PIPE_PUSH=0x1fff)
// The client emits P4a's records for the workload: a framebuffer state per bound target that
// moved, the three unit sets, and the client-side texture upload record. The window's
// emit[fbe= sve= sse= sie= ctu=] bracket therefore carries a NON-ZERO total.
//
// off (MOBILEGL_PIPE_PUSH=0x1ff, P3a's default = P4a's four subsystems cleared)
// The frontend dispatch falls through to the legacy MGB_CTX-reading arms, nothing is emitted
// through any of the four families, and every one of those five counters must read ZERO.
// This is the reading a dead switch fails: with the bits ignored, this lane would report the
// same non-zero counts as the other one.
//
// refused (MOBILEGL_PIPE_PUSH=0x9ff = bits 0..8 plus bit 11, samplers, WITHOUT bit 10)
// D-K2's dependency refusal. Every MGPBoundView::Texture and MGPImageView::Res names a
// Texture handle and only bit 10 populates the texture slot table, so a sampler subsystem
// without it would miss every lookup and walk on without unbinding. The bring-up logs ONE
// error naming BOTH bits, refuses bit 11 and runs the legacy sampler arm - modelled on the
// bit-8-requires-bit-7 refusal that already ships (Managers.cpp:2393-2410). The assertion is
// that the refusal is NAMED and that the run then produces the same pixels as any other
// lane: a refusal that half-ran, or that aborted, would both be failures here.
//
// refused-texture (MOBILEGL_PIPE_PUSH=0x5ff = bits 0..8 plus bit 10, texture resources, WITHOUT
// bit 11)
// D-K2's FOURTH row (ID-15), and the direction the brief originally called harmless.
// MGPTextureParams::BuiltinSampler is a SamplerCso HANDLE and only bit 11 mints sampler
// CSOs, so with bit 10 alone every set_texture_params would carry a null there and the
// applier's Fatal{ProtocolCorruption} is the next thing that happens. Same two assertions
// as the lane above, with the two bits' roles swapped.
//
// both refusal lanes
// "NAMED" means ONE LINE of the library's log, at ERROR severity, that says it REFUSED and
// names both bits. Not a substring anywhere in the file: the word "sampler" appears in
// almost any log the sampler path writes to, and an assertion that cannot go red for its
// stated reason is worse than no assertion (review F-M6).
//
// every lane
// THE PIXELS MUST NOT MOVE. The workload draws one solid-colour quad through a texture, an
// explicit sampler object and a user framebuffer, and every lane 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 counters are emitted by the client-side emitters P4a packages B and C own,
// and this file is written against the P4a contract commit, before either lands. Until then nothing
// emits, the five counters are structurally zero in BOTH lanes, 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 counters' names and passes the answer in as MGITEST_PIPE_OBJECT_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 packages keep control of their own file
// layout - P4a's new client files are headers (D-P), and a glob for a named .cpp would have kept
// this control skipping forever with a reason that had become false.
//
// DIRECTGLES ONLY, and that is the honest scope: P4a migrates Espryt's framebuffer, texture,
// sampler and program paths. Magma's are P7 (D-Q) and register nothing here, so a DirectVulkan lane
// would be measuring the client emitters against a backend nobody asked to change.
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/PipeApplyPeek.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 three ObjectSubsystemControl. 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_OBJECT_SUBSYSTEM_LANE";
constexpr const char* kLaneOn = "on";
constexpr const char* kLaneOff = "off";
constexpr const char* kLaneRefused = "refused";
// D-K2's FOURTH row (ID-15): bit 10 without bit 11. 0x5ff is 0x1ff plus bit 10.
constexpr const char* kLaneRefusedTexture = "refused-texture";
// c0f's two halves (ID-39/ID-40), run at the phase default on BOTH backends: the client
// GATE (a P4a family emits only where a backend registered MGPipeResourceOps) and the
// applier's BELT (every P4a entry point refuses and counts RefusedNoConsumer when none
// did). One lane per backend, because the interesting one is the backend with NO
// consumer - Magma - and the other is the control that says the assertion is not
// vacuously true of a tree where nothing emits at all.
constexpr const char* kLaneConsumer = "consumer";
constexpr const char* kLaneNoConsumer = "no-consumer";
bool LaneIsARefusalLane(const std::string& lane) {
return lane == kLaneRefused || lane == kLaneRefusedTexture;
}
bool LaneIsAConsumerLane(const std::string& lane) {
return lane == kLaneConsumer || lane == kLaneNoConsumer;
}
constexpr int kInset = 2;
constexpr int kTextureSize = 4;
// Enough frames that a per-frame emitter and a per-draw emitter read differently, and few
// enough that one summary window covers exactly this.
constexpr int kDrawsInTheWindow = 4;
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
out vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFS = R"(#version 330 core
in vec2 vUv;
uniform sampler2D uTex;
out vec4 oColor;
void main() { oColor = texture(uTex, vUv); }
)";
struct Vertex {
float x, y;
};
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();
}
// ---- reading the refusal out of the library's own log ------------------------------
//
// THE UNIT IS A LINE, AND THE LINE HAS TO BE THE REFUSAL (review F-M6). The first cut of
// this asked whether the WHOLE FILE contained a lowercase "sampler" and whether it
// contained "texture resource", anywhere, in any order, at any severity. Both are true of
// almost any log the moment the sampler path says anything at all, so the assertion could
// not go red for the reason it claims and the one P4a control that is not vacuous before
// the emitters land would have been vacuous too.
//
// What is matched instead is one line that is ALL of:
// * at ERROR severity - the library writes "[<time>] [<os> <thread>/<TAG>]: <message>",
// one record per line (MG_Util/Debug/Log.cpp), and D-K2 asks for an MGLOG_E. A refusal
// that was demoted to a D or a W is a refusal an operator's log will not carry;
// * carrying the helper's own decision clause, verbatim - so a line that merely
// mentions the two bits (a future summary, a comment echoed into the log) is not
// mistaken for the decision;
// * naming the bit that was SET and the bit it NEEDED, on that same line, AND IN THAT
// ORDER - see the direction check below.
//
// Espryt's text is one MGLOG_E from the helper the three dependent families share
// (Managers.cpp, PipeSubsystemDependencyMissing): "MGPipe: <A> (bit N) is set but <B>
// (bit M) is clear; <why> - REFUSING the dependent bit and running the legacy arm. Set
// both bits, or clear both". Three spellings are accepted per bit - the constant's name,
// "(bit N)", and the hexadecimal mask - so the assertion pins the DECISION and the
// DIRECTION, and not the family-specific prose in <why>.
//
// THE DIRECTION IS THE HALF THIS FILE USED TO BE MISSING (review F-v2-m1). The first form
// of the matcher asked "does the line name bit A?" AND "does the line name bit B?", which
// is a SYMMETRIC conjunction: swapping the two arguments - exactly what separates the
// 0x5ff case from the 0x9ff one below, and what each of their comments claims to be
// doing - could not change the answer, and both cases went green on either line. A
// resolver that refused correctly but printed the MIRROR sentence would have been green
// on a refusal that told the operator the wrong dependency, which is the same class of
// "the log says something plausible" defect that made the whole-file substring search
// (F-M6) worthless one level up. The two resolvers are forty lines apart in one file,
// share this helper and differ only in the `what` string, so the copy-paste is one edit
// away at all times.
//
// What makes the direction readable is the sentence's own shape: the SET bit is named
// before " is set but " and the NEEDED bit between that and " is clear". So the check is
// four offsets in strictly increasing order, and it is the sentence Espryt emits rather
// than a re-statement of it.
constexpr const char* kSaysItRefused = "REFUSING the dependent bit and running the legacy arm";
constexpr const char* kSaysWhichIsSet = " is set but ";
constexpr const char* kSaysWhichIsClear = " is clear";
// The earliest offset at which any accepted spelling of one bit appears, or npos. The
// EARLIEST rather than any: a spelling that also occurs later in <why> (Espryt's
// bit-10-requires-bit-11 sentence says "only bit 11 mints sampler CSOs" in its reason)
// must not be able to satisfy an ordering the first occurrence does not.
std::size_t EarliestSpellingOffset(const std::string& line,
const std::vector<std::string>& spellings) {
std::size_t earliest = std::string::npos;
for (const std::string& spelling : spellings) {
const std::size_t at = line.find(spelling);
if (at != std::string::npos && (earliest == std::string::npos || at < earliest)) {
earliest = at;
}
}
return earliest;
}
// The matching line, or an empty string. Returned rather than a bool so the case can print
// what it found: a reader of a green refusal lane must be able to see the sentence.
std::string FindTheRefusalLine(const std::string& log,
const std::vector<std::string>& bitThatWasSet,
const std::vector<std::string>& bitThatWasNeeded) {
std::size_t pos = 0;
while (pos <= log.size()) {
const std::size_t newline = log.find('\n', pos);
const std::string line = log.substr(
pos, newline == std::string::npos ? std::string::npos : newline - pos);
const bool atErrorSeverity = line.find("/ERROR]") != std::string::npos;
const std::size_t refusedAt = line.find(kSaysItRefused);
const std::size_t setAt = EarliestSpellingOffset(line, bitThatWasSet);
const std::size_t setClauseAt = line.find(kSaysWhichIsSet);
const std::size_t neededAt = EarliestSpellingOffset(line, bitThatWasNeeded);
const std::size_t clearClauseAt = line.find(kSaysWhichIsClear);
const bool everyPartIsThere =
refusedAt != std::string::npos && setAt != std::string::npos &&
setClauseAt != std::string::npos && neededAt != std::string::npos &&
clearClauseAt != std::string::npos;
// "<set bit> ... is set but ... <needed bit> ... is clear", strictly in that
// order. Swapping the caller's two arguments breaks the chain, which is the
// whole of F-v2-m1.
const bool inTheRightDirection =
everyPartIsThere && setAt < setClauseAt && setClauseAt < neededAt &&
neededAt < clearClauseAt;
if (atErrorSeverity && inTheRightDirection) {
return line;
}
if (newline == std::string::npos) break;
pos = newline + 1;
}
return std::string();
}
// The three accepted spellings of each of the two P4a bits this file's two refusal lanes
// are about. MGPipe.h: bit 10 = kMGPipeSubsystemTextureResources = 0x400,
// bit 11 = kMGPipeSubsystemSamplers = 0x800.
std::vector<std::string> SamplerBitSpellings() {
return {"kMGPipeSubsystemSamplers", "(bit 11)", "0x800"};
}
std::vector<std::string> TextureResourceBitSpellings() {
return {"kMGPipeSubsystemTextureResources", "(bit 10)", "0x400"};
}
class ObjectSubsystemControlScenario : 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;
static const Vertex quad[6] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f},
{-1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}};
glGenBuffers(1, &m_quadBuffer);
glBindBuffer(GL_ARRAY_BUFFER, m_quadBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr);
glBindVertexArray(0);
RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str());
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
glBindSampler(0, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_quadBuffer != 0) glDeleteBuffers(1, &m_quadBuffer);
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(bool needsTheEmitters) {
if (m_lane.empty()) {
GTEST_SKIP() << "runs only in its own lane: the three ObjectSubsystemControl. "
"ctest entries set " << kLaneMarker
<< " 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, P4a's five CallClass members do not "
"exist and the summary line carries no emit[...] bracket. 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 (needsTheEmitters && !BuildMarkerIsSet("MGITEST_PIPE_OBJECT_EMITTER_PRESENT")) {
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
"MobileGL/MG_Impl/Pipe/ emits FramebufferEmissions, so nothing "
"sends a P4a record, every counter in the emit[] bracket is "
"structurally zero in BOTH lanes and the difference between them "
"is not observable yet. P4a packages B (framebuffer, texture) "
"and C (sampler, image, program) own those emitters; this "
"control arms itself when they land, whatever files they use.";
return;
}
if (PipeStatsWindow::LibraryLogPath().empty()) {
GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's "
"own log is the only channel this module has for reading "
"PipeStats and the bring-up's refusal line";
return;
}
}
// The workload, and every one of P4a's four families is in it exactly once per draw:
// a USER FRAMEBUFFER with a texture attachment (bit 9), a TEXTURE with parameters and
// an upload (bit 10), an explicit SAMPLER OBJECT on the unit (bit 11) and a PROGRAM
// with a default-uniform-block write (bit 12). A lane that steered only one of the four
// would move only its own counter, which is why they are counted separately.
void RunTheWorkload() {
std::vector<std::uint8_t> texels(kTextureSize * kTextureSize * 4);
for (std::size_t i = 0; i < texels.size(); i += 4) {
texels[i] = 0;
texels[i + 1] = 255;
texels[i + 2] = 0;
texels[i + 3] = 255;
}
glGenTextures(1, &m_texture);
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kTextureSize, kTextureSize, 0, GL_RGBA,
GL_UNSIGNED_BYTE, texels.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
glGenSamplers(1, &m_sampler);
glSamplerParameteri(m_sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glSamplerParameteri(m_sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glSamplerParameteri(m_sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glSamplerParameteri(m_sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// The user framebuffer, drawn into once per iteration so that the framebuffer
// record has a reason to move: the binding alternates between it and the default
// framebuffer, which is exactly what a per-target set_framebuffer_state counts.
glGenTextures(1, &m_attachment);
glBindTexture(GL_TEXTURE_2D, m_attachment);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kTextureSize, kTextureSize, 0, GL_RGBA,
GL_UNSIGNED_BYTE, nullptr);
glBindTexture(GL_TEXTURE_2D, 0);
glGenFramebuffers(1, &m_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D,
m_attachment, 0);
BindDefaultFramebuffer();
for (int draw = 0; draw < kDrawsInTheWindow; ++draw) {
// Into the user framebuffer...
glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
glViewport(0, 0, kTextureSize, kTextureSize);
glUseProgram(m_program);
glUniform1i(glGetUniformLocation(m_program, "uTex"), 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, m_texture);
glBindSampler(0, m_sampler);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
// ...and into the default one, which is what the case reads back.
BindDefaultFramebuffer();
glViewport(0, 0, Gl().Width(), Gl().Height());
glDrawArrays(GL_TRIANGLES, 0, 6);
// One sub-region upload per iteration, so the client-side texture upload
// counter (ctu) has something to count and the server's tex[emit=] has the
// same something.
const std::uint8_t green[4] = {0, 255, 0, 255};
glBindTexture(GL_TEXTURE_2D, m_texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, draw % kTextureSize, 0, 1, 1, GL_RGBA,
GL_UNSIGNED_BYTE, green);
}
}
void ReleaseTheWorkload() {
glBindSampler(0, 0);
glBindTexture(GL_TEXTURE_2D, 0);
BindDefaultFramebuffer();
if (m_fbo != 0) glDeleteFramebuffers(1, &m_fbo);
if (m_sampler != 0) glDeleteSamplers(1, &m_sampler);
if (m_texture != 0) glDeleteTextures(1, &m_texture);
if (m_attachment != 0) glDeleteTextures(1, &m_attachment);
m_fbo = m_sampler = m_texture = m_attachment = 0;
}
std::string m_lane;
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_quadBuffer = 0;
GLuint m_texture = 0;
GLuint m_attachment = 0;
GLuint m_sampler = 0;
GLuint m_fbo = 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 in the same lane would race this
// one under `ctest -j` with a failure indistinguishable from "the counter was never
// emitted". The CMake registration gives each lane a TEST_FILTER naming one case.
TEST_F(ObjectSubsystemControlScenario, ClearingTheP4aBitsStopsTheEmissionsAndNotThePixels) {
if (!Ready()) return;
SkipUnlessTheLaneIsAssertableHere(/*needsTheEmitters=*/true);
if (IsSkipped()) return;
if (LaneIsAConsumerLane(m_lane)) {
GTEST_SKIP() << "the two consumer lanes run their own case instead "
"(TheAppliersNoConsumerBeltNeverFiresBehindTheClientsGate). They "
"are at the phase default on both backends and their subject is "
"c0f's gate/belt pair, not the on/off A/B: on the backend with no "
"consumer the emit[] bracket is structurally zero AT the default "
"mask, which is neither the on-lane's expectation nor the "
"off-lane's.";
}
if (LaneIsARefusalLane(m_lane)) {
GTEST_SKIP() << "the refusal lanes run their own case instead (0x9ff -> "
"ASamplerBitWithoutTheTextureBitIsRefusedAndNamed, 0x5ff -> "
"ATextureBitWithoutTheSamplerBitIsRefusedAndNamed): a refused "
"subsystem's emission counts are neither the on-lane's nor the "
"off-lane's, and asserting either would be reading a third arm as "
"if it were one of the two.";
}
BindDefaultFramebuffer();
Gl().EndFrame(); // close the setup window: everything below is one window
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RunTheWorkload();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the workload left a GL error behind";
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());
// The five counters of the emit[] bracket, read individually so that a lane which
// steered one family and not another says WHICH.
const long long framebuffer = PipeStatsWindow::CounterOrAbsent(window, "fbe");
const long long samplerViews = PipeStatsWindow::CounterOrAbsent(window, "sve");
const long long samplerStates = PipeStatsWindow::CounterOrAbsent(window, "sse");
const long long shaderImages = PipeStatsWindow::CounterOrAbsent(window, "sie");
const long long clientUploads = PipeStatsWindow::CounterOrAbsent(window, "ctu");
ASSERT_GE(framebuffer, 0)
<< "the summary line carries no fbe= field, so this build's PipeStats has no P4a "
"emission counters to read: "
<< window.line;
ASSERT_GE(samplerViews, 0) << "no sve= field: " << window.line;
ASSERT_GE(samplerStates, 0) << "no sse= field: " << window.line;
ASSERT_GE(shaderImages, 0) << "no sie= field: " << window.line;
ASSERT_GE(clientUploads, 0) << "no ctu= field: " << window.line;
const long long total = framebuffer + samplerViews + samplerStates + shaderImages +
clientUploads;
if (m_lane == kLaneOn) {
EXPECT_GT(total, 0)
<< "with bits 9|10|11|12 SET the four P4a families are the path this workload "
"takes - a user framebuffer bound and unbound "
<< kDrawsInTheWindow
<< " times, a texture with parameters and a sub-region upload per iteration, an "
"explicit sampler object on the unit and a program with a default-uniform "
"write - so the window's emit[] bracket must carry something. All five "
"reading zero means the emitters never ran on the arm that is supposed to run "
"them. It reported: "
<< window.line;
// The framebuffer family on its own, because it is the one that would be hidden by
// a large upload count: a suppressor that stopped suppressing shows up as fbe
// tracking the DRAW count, and a family that never emitted shows up as zero.
EXPECT_GT(framebuffer, 0)
<< "fbe= is zero on the ON lane: set_framebuffer_state never went out even "
"though the workload bound a user framebuffer and the default framebuffer "
<< kDrawsInTheWindow << " times each. " << window.line;
} else if (m_lane == kLaneOff) {
EXPECT_EQ(total, 0)
<< "with bits 9|10|11|12 CLEARED (MOBILEGL_PIPE_PUSH=0x1ff, P3a's default) the "
"frontend dispatch must fall through to the legacy MGB_CTX-reading arms and "
"emit nothing through any of the four P4a families, so every counter in the "
"emit[] bracket 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 / refused / refused-texture / consumer / "
"no-consumer. Reading an unrecognised name as any of them would make "
"this lane assert another arm's expectation while claiming to test "
"this one.";
}
// ... and the picture is the same whichever arm ran.
EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset,
image.Height() - kInset, "green", 0.0,
"the sampled 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 texture, sampler "
"and framebuffer.";
ReleaseTheWorkload();
}
// ------------------------------------------------------------------------------------
// D-K2's dependency refusal, in the direction that has to be refused.
//
// 0x9ff is bits 0..8 (everything P3a shipped) plus bit 11 (samplers) and WITHOUT bit 10
// (texture resources). Every MGPBoundView::Texture and every MGPImageView::Res names a
// Texture handle, and only bit 10 populates the texture slot table, so with bit 11 alone
// every lookup would miss and the unit walk would `continue` without unbinding - a
// half-run subsystem, which ROADMAP.md:7 forbids as loudly as a dead switch. The bring-up
// logs ONE error naming both bits, refuses bit 11, and runs the legacy sampler arm.
//
// TWO ASSERTIONS, and the second is the one that stops this from being a log-scraping test:
// the refusal is NAMED in the library's own log, and the run then draws the same picture as
// every other lane. A refusal that aborted the process, and a refusal that silently let the
// half-configured arm run, are both failures - and they look completely different here.
// ------------------------------------------------------------------------------------
TEST_F(ObjectSubsystemControlScenario, ASamplerBitWithoutTheTextureBitIsRefusedAndNamed) {
if (!Ready()) return;
// needsTheEmitters=false: the refusal is a BRING-UP decision made from the bitmask
// alone, so it is assertable before any emitter exists - which is exactly what makes it
// the one P4a control that is not vacuous on the contract tree.
SkipUnlessTheLaneIsAssertableHere(/*needsTheEmitters=*/false);
if (IsSkipped()) return;
if (m_lane != kLaneRefused) {
GTEST_SKIP() << "runs only in the refusal lane (MOBILEGL_PIPE_PUSH=0x9ff): the "
"on/off lanes configure a mask whose dependencies are all satisfied, "
"so there is no refusal there to find and a search for one would "
"report a healthy lane as red.";
}
// The refusal is decided from the bitmask, but it is a BACKEND's decision: D-K2 puts it
// in ResolveSamplersSubsystemArm(), beside the bit-8-requires-bit-7 refusal that
// already ships, and that function is package D's (Managers.cpp). A backend that does
// not yet honour P4a's mask at all cannot refuse a dependency inside it, so on such a
// tree there is nothing here to find and this case SKIPS rather than reporting the
// absence of an unimplemented subsystem as a failure. The marker is the same one
// HandleRecycle's P4a cases read - "does any source under this backend name one of the
// four P4a subsystem constants" - because naming the constant is exactly what honouring
// the mask means.
{
const std::string& backend = Gl().BackendName();
const std::string marker =
"MGITEST_HANDLE_REKEY_OBJECTS_" + (backend == "DirectVulkan"
? std::string("DirectVulkan")
: std::string("DirectGLES"));
if (!BuildMarkerIsSet(marker.c_str())) {
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
"MobileGL/MG_Backend/"
<< backend
<< " names any of kMGPipeSubsystem{Framebuffer, TextureResources, "
"Samplers, Programs}, so this backend does not honour P4a's mask "
"and cannot refuse a dependency inside it. D-K2's refusal lives "
"in ResolveSamplersSubsystemArm() beside the bit-8-requires-bit-7 "
"one that already ships (Managers.cpp:2393-2410), which is P4a "
"package D's file; this control arms itself when that lands. The "
"lane itself is not wasted: the library came up under 0x9ff, "
"which on a tree with no P4a arm is P3a's mask plus one inert "
"bit, and a mask that aborted a bring-up would have failed this "
"entry before the skip.";
}
}
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RunTheWorkload();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR))
<< "the workload left a GL error behind on the refused lane, which would mean the "
"refusal did not fall back cleanly to the legacy arm";
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
const std::string log = PipeStatsWindow::ReadWholeFile(PipeStatsWindow::LibraryLogPath());
ASSERT_FALSE(log.empty())
<< "the library wrote nothing to " << PipeStatsWindow::LibraryLogPath()
<< ", so the refusal cannot be read back. MOBILEGL_LOG_FILE_PATH is the only channel "
"this module has for the library's own report.";
// ONE LINE, at ERROR severity, saying it refused and naming BOTH bits. See
// FindTheRefusalLine: a substring search over the whole file cannot go red for the
// reason this case claims (F-M6).
const std::string refusal =
FindTheRefusalLine(log, SamplerBitSpellings(), TextureResourceBitSpellings());
EXPECT_FALSE(refusal.empty())
<< "MOBILEGL_PIPE_PUSH=0x9ff sets the sampler subsystem (bit 11) without the texture "
"resource subsystem (bit 10) it depends on, and no single ERROR line of the "
"library's log both says it REFUSED and names the two bits. D-K2 requires ONE "
"MGLOG_E naming both and a fall back to the legacy sampler arm; a mask that is "
"silently half-honoured is the failure this case exists to catch, and it is "
"invisible in the pixels by construction. Accepted spellings per bit are the "
"constant's name, '(bit 11)' / '(bit 10)', and '0x800' / '0x400'. The log was "
<< log.size() << " bytes and is at " << PipeStatsWindow::LibraryLogPath() << ".";
if (!refusal.empty()) {
// Printed on the pass as well: a reader of a green refusal lane must be able to
// see the sentence the lane went green on.
std::cout << "[ ObjectSubsystemControl ] refusal line: " << refusal << std::endl;
RecordProperty("refusal_line", refusal.c_str());
}
EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset,
image.Height() - kInset, "green", 0.0,
"the sampled draw [refused]"))
<< "the refused configuration did not draw what every other lane draws. A refusal is "
"supposed to run the LEGACY arm, which is the arm that ships in a pull build - so "
"the pixels are the one thing it may not change.";
ReleaseTheWorkload();
}
// ------------------------------------------------------------------------------------
// D-K2's FOURTH dependency row, in the OTHER direction: bit 10 without bit 11 (ID-15).
//
// 0x5ff is bits 0..8 plus bit 10 (texture resources) and WITHOUT bit 11 (samplers).
//
// WHY THIS IS A REFUSAL AND NOT THE "FINE" MIRROR PAIR THE BRIEF ORIGINALLY CALLED IT.
// BRIEF-P4A.md's D-K2 says "bit 10 without bit 11 is fine", and that sentence is wrong for
// P4a AS BUILT: MGPTextureParams carries a BuiltinSampler, which is a SamplerCso HANDLE,
// and only bit 11 mints sampler CSOs - c0b's four unconditional mints deliberately exclude
// that kind (contract-v2.md), and package C content-addresses them through its own cache
// (ID-14). With bit 10 set and bit 11 clear every set_texture_params would therefore carry
// a NULL BuiltinSampler, which the applier treats as Fatal{ProtocolCorruption} (wire H1),
// and minting it client-side in the arm that exists to exclude samplers was rejected. So
// the dependency is real and it has to be refused at bring-up, exactly like bit 11 without
// bit 10 above and bit 8 without bit 7 one phase earlier. ID-15 puts the refusal in the
// texture family's Resolve*SubsystemArm - package D's Managers.cpp - and this case is the
// pin that says it is there.
//
// ON A TREE WHOSE BACKEND DOES NOT HONOUR P4a's MASK THIS SKIPS, NAMED, exactly as the
// 0x9ff case does and for the same reason: a backend that never reads the four constants
// cannot refuse a dependency between two of them, and reporting the absence of an
// unimplemented subsystem as a failure is what ID-2 forbids. Once the backend DOES name
// them the case is a hard pin, which is the point - if D's texture-family resolver honours
// the mask and does not carry this row, this entry is where that shows.
//
// WHAT THIS ARM DOES ON THE INTEGRATED TREE, corrected (review F-v2-m2). An earlier
// round's report told the integrator to expect this lane to go RED between esprytobj's
// integration and package D's rework, and to read that red as expected. That window does
// not exist: esprytobj v2 already carries D-K2's fourth row - Managers.cpp's
// ResolveTextureResourceSubsystemArm refuses bit 10 without bit 11 with the sentence this
// case matches - so the arm ARMS AND PASSES. A red here is therefore a real finding about
// that resolver (it stopped refusing, refused for the wrong reason, or printed the mirror
// sentence, which the direction check above is what catches) and must not be waved
// through as a sequencing artefact.
// ------------------------------------------------------------------------------------
TEST_F(ObjectSubsystemControlScenario, ATextureBitWithoutTheSamplerBitIsRefusedAndNamed) {
if (!Ready()) return;
// needsTheEmitters=false, for the 0x9ff case's reason: a bring-up decision made from
// the bitmask alone is assertable before any emitter exists.
SkipUnlessTheLaneIsAssertableHere(/*needsTheEmitters=*/false);
if (IsSkipped()) return;
if (m_lane != kLaneRefusedTexture) {
GTEST_SKIP() << "runs only in the texture-side refusal lane "
"(MOBILEGL_PIPE_PUSH=0x5ff): every other lane configures a mask "
"whose dependencies are satisfied or a different refusal, so there "
"is nothing here to find and a search for one would report a "
"healthy lane as red.";
}
{
const std::string& backend = Gl().BackendName();
const std::string marker =
"MGITEST_HANDLE_REKEY_OBJECTS_" + (backend == "DirectVulkan"
? std::string("DirectVulkan")
: std::string("DirectGLES"));
if (!BuildMarkerIsSet(marker.c_str())) {
GTEST_SKIP() << "subsystem not implemented on this tree: no source under "
"MobileGL/MG_Backend/"
<< backend
<< " names any of kMGPipeSubsystem{Framebuffer, TextureResources, "
"Samplers, Programs}, so this backend does not honour P4a's mask "
"and cannot refuse a dependency inside it. D-K2's fourth row "
"(bit 10 requires bit 11, ID-15) lives in the texture family's "
"Resolve*SubsystemArm beside the bit-11-requires-bit-10 and "
"bit-8-requires-bit-7 refusals, which is P4a package D's file; "
"this control arms itself when that lands. The lane itself is "
"not wasted: the library came up under 0x5ff, which on a tree "
"with no P4a arm is P3a's mask plus one inert bit, and a mask "
"that aborted a bring-up would have failed this entry before "
"the skip.";
}
}
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RunTheWorkload();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR))
<< "the workload left a GL error behind on the refused lane, which would mean the "
"refusal did not fall back cleanly to the legacy arm";
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
const std::string log = PipeStatsWindow::ReadWholeFile(PipeStatsWindow::LibraryLogPath());
ASSERT_FALSE(log.empty())
<< "the library wrote nothing to " << PipeStatsWindow::LibraryLogPath()
<< ", so the refusal cannot be read back. MOBILEGL_LOG_FILE_PATH is the only channel "
"this module has for the library's own report.";
// The same line shape as the 0x9ff arm, with the two bits' roles swapped: the bit that
// was SET is the texture-resource one and the bit it NEEDED is the sampler one. The
// swap is now a REAL difference between the two cases: FindTheRefusalLine requires the
// set bit to be named before " is set but " and the needed bit after it (F-v2-m1), so
// this call and the 0x9ff one above accept disjoint sentences. Espryt's is
// "kMGPipeSubsystemTextureResources (bit 10) is set but kMGPipeSubsystemSamplers
// (bit 11) is clear; MGPTextureParams::BuiltinSampler is a SamplerCso handle, only
// bit 11 mints sampler CSOs, and the applier's verdict for a null one is
// Fatal{ProtocolCorruption} - REFUSING the dependent bit and running the legacy arm.
// Set both bits, or clear both" (Managers.cpp, ResolveTextureResourceSubsystemArm).
const std::string refusal =
FindTheRefusalLine(log, TextureResourceBitSpellings(), SamplerBitSpellings());
EXPECT_FALSE(refusal.empty())
<< "MOBILEGL_PIPE_PUSH=0x5ff sets the texture resource subsystem (bit 10) without "
"the sampler subsystem (bit 11) that MGPTextureParams::BuiltinSampler depends on, "
"and no single ERROR line of the library's log both says it REFUSED and names the "
"two bits. Only bit 11 mints sampler CSOs, so every set_texture_params emitted "
"under this mask would carry a null BuiltinSampler and the applier's Fatal is the "
"next thing that happens - which is why this pair is a refusal at bring-up and "
"not the harmless mirror of the 0x9ff one. Accepted spellings per bit are the "
"constant's name, '(bit 10)' / '(bit 11)', and '0x400' / '0x800'. The log was "
<< log.size() << " bytes and is at " << PipeStatsWindow::LibraryLogPath() << ".";
if (!refusal.empty()) {
std::cout << "[ ObjectSubsystemControl ] refusal line: " << refusal << std::endl;
RecordProperty("refusal_line", refusal.c_str());
}
EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset,
image.Height() - kInset, "green", 0.0,
"the sampled draw [refused-texture]"))
<< "the refused configuration did not draw what every other lane draws. A refusal is "
"supposed to run the LEGACY arm, which is the arm that ships in a pull build - so "
"the pixels are the one thing it may not change.";
ReleaseTheWorkload();
}
// ------------------------------------------------------------------------------------
// c0f's GATE AND BELT, MEASURED TOGETHER (ID-39, ID-40).
//
// WHAT WENT WRONG AND WHY IT NEEDS A LANE. P4a's four families were wired without the
// gate P3a's buffers have had since PipeFill.cpp ~656: emission required the family's bit
// and nothing else. On Magma, which registers no MGPipeResourceOps, the client therefore
// emitted, THE APPLIER ACCEPTED, the client cleared its dirty flags on that acceptance -
// and Magma's legacy path then found nothing to upload. Sixty-six DirectVulkan cases went
// red at once, all texture-upload-shaped, and every one of them was green at 0x1ff. The
// fix has two halves that are deliberately independent: the client's gate (bit N AND
// wired AND a backend registered the ops) and the applier's belt (every P4a entry point
// returns accepted = false and counts RefusedNoConsumer when none did).
//
// THE ASSERTION IS THAT THE BELT NEVER FIRES, and it is the same assertion on both
// backends, which is what makes it worth having twice:
//
// no-consumer (DirectVulkan, 0x1fff): the belt is the SAFETY NET. A non-zero count here
// means a record reached the applier on a backend with no consumer - i.e. the client
// gate leaked and only the belt stopped the dirty flag from being cleared. That is
// ID-39's bug caught one layer later, and it is invisible in these pixels because the
// belt does its job; the 66 red cases were in another suite entirely.
// consumer (DirectGLES, 0x1fff): the CONTROL. Espryt registers the ops, so no entry
// point may take the no-consumer arm at all. Without this lane a green above could
// also mean "nothing is ever emitted anywhere", which is exactly what a gate that was
// accidentally always-false would look like.
//
// A DELTA, not an absolute: the counter is process-global and other cases in this binary
// run before this one. MGPipeApplierReset also zeroes it, so a count that went DOWN is
// read as "the applier was reset and everything since is `after`" rather than as an
// underflow.
// ------------------------------------------------------------------------------------
TEST_F(ObjectSubsystemControlScenario, TheAppliersNoConsumerBeltNeverFiresBehindTheClientsGate) {
if (!Ready()) return;
// needsTheEmitters=false: the assertion is that a counter did NOT move, which is
// meaningful before the emitters land as well as after - and on the no-consumer lane
// it is meaningful precisely BECAUSE nothing may be emitted there.
SkipUnlessTheLaneIsAssertableHere(/*needsTheEmitters=*/false);
if (IsSkipped()) return;
if (!LaneIsAConsumerLane(m_lane)) {
GTEST_SKIP() << "runs only in the two consumer lanes (MGITEST_OBJECT_SUBSYSTEM_LANE="
<< kLaneConsumer << " / " << kLaneNoConsumer
<< "), which pin MOBILEGL_PIPE_PUSH at the phase default on the two "
"backends. Every other lane configures a mask or a backend whose "
"emission shape is a different question.";
}
unsigned long long before = 0;
if (!PeekPipeApplierRefusedNoConsumer(&before)) {
GTEST_SKIP() << "MGPipeApplierState::RefusedNoConsumer is out of reach here: there "
"is no applier in a PULL build (it is #if MOBILEGL_PIPE_PUSH), and "
"on Android this module links the shipping libMobileGL.so built "
"-fvisibility=hidden. 'Could not look' is not 'did not fire'.";
}
BindDefaultFramebuffer();
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
RunTheWorkload();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR))
<< "the workload left a GL error behind on the " << m_lane << " lane";
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
unsigned long long after = 0;
ASSERT_TRUE(PeekPipeApplierRefusedNoConsumer(&after))
<< "the counter could be read before the workload and not after it";
// Down means MGPipeApplierReset ran inside the window, so everything still counted is
// what happened since - which is the number this case is about either way.
const unsigned long long fired = after >= before ? after - before : after;
std::cout << "[ ObjectSubsystemControl ] " << m_lane
<< " lane: applier RefusedNoConsumer " << before << " -> " << after
<< " over the workload (delta " << fired << ")" << std::endl;
RecordProperty("refused_no_consumer_delta", static_cast<int>(fired));
EXPECT_EQ(fired, 0u)
<< "the applier's no-consumer BELT fired " << fired
<< " time(s) during this workload on the " << m_lane
<< " lane. The belt exists so that a P4a record arriving on a backend that "
"registered no MGPipeResourceOps is refused rather than accepted - and an "
"accepted record is what makes the client clear the dirty flags whose texels "
"nobody then uploads (ID-39: sixty-six DirectVulkan cases, all texture-upload "
"shaped). A non-zero count means the CLIENT'S GATE let an emission through and "
"only the belt caught it: the two are supposed to agree, and PipeFill's "
"P4aFamilyHasItsConsumer() is where they stopped.";
// The pixels, on both lanes, for the reason every arm of this file asserts them: a
// backend running its legacy path because no consumer is registered must draw exactly
// what a backend running the handle arm draws.
EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset,
image.Height() - kInset, "green", 0.0,
"the sampled draw [" + m_lane + "]"))
<< "the " << m_lane
<< " lane did not draw what every other lane draws, so whatever the counter says, "
"this configuration is not running the workload correctly.";
ReleaseTheWorkload();
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,658 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/P4aFinalFixScenario.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 THREE FINDINGS OF THE P4a FINAL WHOLE-DIFF REVIEW (final-review-v1.md C-1, C-2,
// M-A), each pinned by the public-GL sequence that was red on the tree the review read and is
// green with its fix. Every sequence here is legal GL and none of the 80-odd scenarios before
// this file drove it, which is how two criticals shipped through a green gate.
//
// C-1 The client never passed the applier the LEVEL a respecify redefines, so every per-level
// glTexImage*D / glGenerateMipmap grow took the applier's whole-resource arm and dropped
// EVERY pending upload of the texture - including a level the applier had already
// accepted and whose client-side dirty flag was therefore already clear (D-D5 step 1).
// Nobody owed those texels any more. The window is "accepted but not yet consumed":
// a verb the texture is not reached by (a draw with another texture) drains the level
// into the applier, Espryt does not sync the texture, and the next level definition eats
// the entry. Two hazard cases (a level-1 definition, a glGenerateMipmap) read a black
// level 0 on the handle arm; the three controls beside them (no verb between, level 0
// consumed first, an immediate generate) are red on every arm, which is what pins the
// window rather than the mip path.
// C-2 A dead-but-not-recycled texture handle still resolved to the freed ITextureObject*
// inside the client's drain: the death helper freed the slot without telling the emitter,
// the drain list kept the level, and the next verb's drain called virtual
// GetStorageType() on freed memory - `glTexImage2D; glDeleteTextures; <any verb>` was a
// SIGABRT ("pure virtual method called") at the shipping default mask. The same
// delete-then-use shape is driven for every kind P4a mints (renderbuffer, sampler object,
// program, framebuffer) and for a slot recycled straight after the death (ABA), on both
// backends: the death path is backend-neutral by ruling (ID-8) and the DirectVulkan lane
// must see it too.
// M-A Nothing produced kMGPipeBindSampler / kMGPipeBindShaderImage, so ImageBindableHint was
// dead: the applier never saw a texture become image-bound, the metadata respecify
// (ID-18 M4) had no live trigger, and the remint pull the hint exists to prevent was
// neither prevented nor counted. The case here reads the applier's record around a
// glBindImageTexture: the hint arrives as a metadata update that keeps the pending upload
// standing beside it, and the picture after the transition is the texels that upload
// carried.
//
// A WHITE-BOX READING THAT CANNOT BE TAKEN IS DECLINED BY NAME AND THE CASE CONTINUES with its
// public-GL half (P4aSeamAuditScenario.cpp's shape): a pull build or a backend with no P4a
// consumer holds no record to read, and skipping the whole case there would delete the verdict
// those lanes carry. The C-1 and M-A cases assert their pictures on DirectGLES only - Espryt is
// the one consumer of the texture records this phase wires, so on any other backend the handle
// arm is inert by design and the picture proves nothing about it.
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/P4aFinalFixPeek.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 {
constexpr int kInset = 2;
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
out vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFS = R"(#version 330 core
in vec2 vUv;
uniform sampler2D uTex;
out vec4 oColor;
void main() { oColor = texture(uTex, vUv); }
)";
struct Vertex {
float x, y;
};
class P4aFinalFixScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(m_program, 0u) << error;
static const Vertex quad[6] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f},
{-1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}};
glGenBuffers(1, &m_quadBuffer);
glBindBuffer(GL_ARRAY_BUFFER, m_quadBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr);
glBindVertexArray(0);
glDisable(GL_BLEND);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
// The "other" texture: a complete, single-level white texture, so a draw that
// samples it is a verb the texture under test is not reached by.
m_other = MakeLevel0(255, 255, 255, /*maxLevel=*/0);
while (glGetError() != GL_NO_ERROR) {
}
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
if (m_other != 0) glDeleteTextures(1, &m_other);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_quadBuffer != 0) glDeleteBuffers(1, &m_quadBuffer);
if (m_program != 0) glDeleteProgram(m_program);
while (glGetError() != GL_NO_ERROR) {
}
}
// The C-1 and M-A pictures are about Espryt's consumption of the texture records;
// Magma registers no consumer for the P4a families (c0f), so the handle arm is inert
// there by design and a green picture proves nothing about the finding. Marks the
// case skipped; the caller tests IsSkipped() and returns.
void SkipUnlessEspryt(const char* what) {
if (Gl().BackendName() == "DirectGLES") return;
GTEST_SKIP() << what << " is consumed by DirectGLES only; backend is " << Gl().BackendName();
}
static std::vector<std::uint8_t> Solid(int size, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
std::vector<std::uint8_t> texels(static_cast<std::size_t>(size) * size * 4);
for (std::size_t i = 0; i < texels.size(); i += 4) {
texels[i] = r;
texels[i + 1] = g;
texels[i + 2] = b;
texels[i + 3] = 255;
}
return texels;
}
// A 4x4 level 0 of one colour, NEAREST_MIPMAP_NEAREST with the level range clamped
// to `maxLevel`, so a single-level texture is complete and a chain is complete once
// its levels exist.
static GLuint MakeLevel0(std::uint8_t r, std::uint8_t g, std::uint8_t b, int maxLevel, int size = 4) {
const std::vector<std::uint8_t> texels = Solid(size, r, g, b);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, size, size, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, maxLevel);
glBindTexture(GL_TEXTURE_2D, 0);
return texture;
}
static void DefineLevel1(GLuint texture, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
const std::vector<std::uint8_t> texels = Solid(2, r, g, b);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1);
glBindTexture(GL_TEXTURE_2D, 0);
}
// A full-viewport draw sampling `texture` on unit 0 through `program` (the fixture's
// by default). The viewport is far larger than the 4x4 base level, so this is
// MAGNIFICATION and reads LEVEL 0 whatever the chain holds above it.
Image DrawSampled(GLuint texture, GLuint program = 0) {
if (program == 0) program = m_program;
BindDefaultFramebuffer();
glViewport(0, 0, Gl().Width(), Gl().Height());
glUseProgram(program);
glUniform1i(glGetUniformLocation(program, "uTex"), 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
Image image = ReadPixels(Gl().Width(), Gl().Height());
glBindTexture(GL_TEXTURE_2D, 0);
glBindVertexArray(0);
Gl().EndFrame();
return image;
}
::testing::AssertionResult Mostly(const Image& image, const char* color, const std::string& when) {
return RegionIsMostly(image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, color,
0.0, when);
}
void Report(const char* caseName, const Image& image) {
const char* mask = std::getenv("MOBILEGL_PIPE_PUSH");
const int cx = image.Width() / 2;
const int cy = image.Height() / 2;
std::cout << "[ P4aFinalFix ] case=" << caseName << " backend=" << Gl().BackendName()
<< " MOBILEGL_PIPE_PUSH=" << (mask ? mask : "(unset)") << " centre=" << image.At(cx, cy)
<< " (" << image.ColorName(cx, cy) << ")" << std::endl;
}
// The white-box gate of the M-A case: true when the applier holds a record for the
// texture in this process. Prints the decline.
bool RecordIsReadable(unsigned glTextureName, const char* what, PipeTextureResourceRecordPeek* out) {
if (PeekPipeTextureResourceRecord(glTextureName, out)) return true;
std::cout << "[ P4aFinalFix ] white-box reading DECLINED for " << what
<< ": the applier holds no record for texture " << glTextureName
<< " (a pull build, or a backend with no P4a consumer); the public-GL half of "
"the case still runs"
<< std::endl;
RecordProperty("p4a_finalfix_white_box", "declined");
return false;
}
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_quadBuffer = 0;
GLuint m_other = 0;
};
// ======================================================================================
// C-1: a per-level definition around a verb the texture is not reached by
// ======================================================================================
// THE HAZARD. L0's upload is accepted at the unrelated draw's validate point (the client
// clears its flag), Espryt never syncs T there (it is bound nowhere), then the level-1
// definition respecifies the resource. Before the fix that respecify carried no level and
// the applier dropped every pending upload; level 0 was allocated undefined.
TEST_F(P4aFinalFixScenario, PerLevelDefinitionAcrossAnUnrelatedDraw) {
if (!Ready()) return;
SkipUnlessEspryt("C-1's per-level respecify");
if (IsSkipped()) return;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0);
const Image unrelated = DrawSampled(m_other);
EXPECT_TRUE(Mostly(unrelated, "white", "the unrelated draw"));
DefineLevel1(texture, 255, 0, 0);
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("PerLevelDefinitionAcrossAnUnrelatedDraw", image);
EXPECT_TRUE(Mostly(image, "red",
"level 0 after a level-1 definition that followed a draw the texture was not "
"reached by - its accepted-but-unconsumed upload was dropped by the whole-"
"resource arm"));
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// CONTROL: both levels defined before any verb; both are pending at the first sync.
TEST_F(P4aFinalFixScenario, ConsecutiveDefinitionsNoVerbBetween) {
if (!Ready()) return;
SkipUnlessEspryt("C-1's per-level respecify");
if (IsSkipped()) return;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0);
DefineLevel1(texture, 255, 0, 0);
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("ConsecutiveDefinitionsNoVerbBetween", image);
EXPECT_TRUE(Mostly(image, "red", "level 0 with both levels defined back to back"));
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// CONTROL: level 0 is consumed by Espryt (T is sampled) before level 1 is defined.
TEST_F(P4aFinalFixScenario, LevelZeroConsumedBeforeLevelOne) {
if (!Ready()) return;
SkipUnlessEspryt("C-1's per-level respecify");
if (IsSkipped()) return;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0);
const Image first = DrawSampled(texture);
EXPECT_TRUE(Mostly(first, "red", "level 0 alone"));
DefineLevel1(texture, 255, 0, 0);
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("LevelZeroConsumedBeforeLevelOne", image);
EXPECT_TRUE(Mostly(image, "red", "level 0 after level 1 was added to a synced texture"));
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// THE HAZARD, glGenerateMipmap flavour: the frontend grows the level chain (one
// AllocateStorage -> respecify per level) BEFORE the backend generate runs, with level 0
// accepted-but-unconsumed. The driver then built the chain from an undefined level 0.
TEST_F(P4aFinalFixScenario, GenerateMipmapAcrossAnUnrelatedDraw) {
if (!Ready()) return;
SkipUnlessEspryt("C-1's per-level respecify");
if (IsSkipped()) return;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/1000);
const Image unrelated = DrawSampled(m_other);
EXPECT_TRUE(Mostly(unrelated, "white", "the unrelated draw"));
glBindTexture(GL_TEXTURE_2D, texture);
glGenerateMipmap(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, 0);
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("GenerateMipmapAcrossAnUnrelatedDraw", image);
EXPECT_TRUE(Mostly(image, "red",
"level 0 after a glGenerateMipmap that followed a draw the texture was not "
"reached by"));
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// CONTROL for the generate: no verb between the upload and the generate.
TEST_F(P4aFinalFixScenario, GenerateMipmapImmediately) {
if (!Ready()) return;
SkipUnlessEspryt("C-1's per-level respecify");
if (IsSkipped()) return;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/1000);
glBindTexture(GL_TEXTURE_2D, texture);
glGenerateMipmap(GL_TEXTURE_2D);
glBindTexture(GL_TEXTURE_2D, 0);
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("GenerateMipmapImmediately", image);
EXPECT_TRUE(Mostly(image, "red", "level 0 after an immediate glGenerateMipmap"));
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// ======================================================================================
// C-2: delete-then-use, for every kind P4a mints, on both backends
// ======================================================================================
// A level goes dirty, the texture dies before any verb, and the next verb's drain walks
// the entry. Before the fix the emitter resolved the dead handle to the freed object and
// the drain called a virtual on it: SIGABRT in the first round. Eight rounds, and the
// lane registered with MALLOC_PERTURB_ scribbles every freed block so a resolved-but-
// dead pointer faults rather than reads the object's ghost.
TEST_F(P4aFinalFixScenario, ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain) {
if (!Ready()) return;
for (int round = 0; round < 8; ++round) {
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
const std::vector<std::uint8_t> texels = Solid(4, 255, 0, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data());
glBindTexture(GL_TEXTURE_2D, 0);
glDeleteTextures(1, &texture); // the last reference: the frontend object dies here
// Something else is allocated between the death and the drain, so the freed
// storage is not simply re-handed to the next object.
std::vector<std::uint8_t> churn(4096 + round * 1024, static_cast<std::uint8_t>(round));
(void)churn;
const Image image = DrawSampled(m_other); // the validate point: the drain runs here
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
if (round == 0) Report("ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain", image);
EXPECT_TRUE(Mostly(image, "white", "the draw after a dirty texture died"));
}
}
// ABA: the slot the dead texture held is handed straight to the next texture (the free
// list is LIFO). The new texture's picture must be its own, and the dead one's drain
// entry must not be replayed onto it.
TEST_F(P4aFinalFixScenario, ATextureRecycledOntoTheDeadSlotDoesNotInheritItsDrainEntry) {
if (!Ready()) return;
{
GLuint dead = 0;
glGenTextures(1, &dead);
glBindTexture(GL_TEXTURE_2D, dead);
const std::vector<std::uint8_t> texels = Solid(4, 255, 0, 0);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data());
glBindTexture(GL_TEXTURE_2D, 0);
glDeleteTextures(1, &dead); // dirty, dead, no verb between
}
const GLuint successor = MakeLevel0(0, 0, 255, /*maxLevel=*/0, /*size=*/8);
const Image image = DrawSampled(successor);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("ATextureRecycledOntoTheDeadSlotDoesNotInheritItsDrainEntry", image);
EXPECT_TRUE(Mostly(image, "blue", "the successor of a dead dirty texture on the recycled slot"));
const Image other = DrawSampled(m_other);
EXPECT_TRUE(Mostly(other, "white", "an unrelated draw after the recycled slot was used"));
GLuint cleanup = successor;
glDeleteTextures(1, &cleanup);
}
// A renderbuffer with defined storage, attached, cleared through its framebuffer, then
// both die before the next verb.
TEST_F(P4aFinalFixScenario, ARenderbufferAndItsFramebufferDeletedAfterAClearLeaveTheNextDrawIntact) {
if (!Ready()) return;
GLuint renderbuffer = 0;
glGenRenderbuffers(1, &renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 8, 8);
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
glViewport(0, 0, 8, 8);
glClearColor(0.0f, 1.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
const Image cleared = ReadPixels(8, 8);
EXPECT_TRUE(RegionIsMostly(cleared, 0, 8, 0, 8, "green", 0.0, "the renderbuffer after the clear"));
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteRenderbuffers(1, &renderbuffer); // the attachment's last reference went with the FBO
const Image image = DrawSampled(m_other);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("ARenderbufferAndItsFramebufferDeletedAfterAClearLeaveTheNextDrawIntact", image);
EXPECT_TRUE(Mostly(image, "white", "the draw after a renderbuffer and its framebuffer died"));
}
// A sampler object bound to the unit the draw samples through, deleted while bound: GL
// unbinds it from every unit at glDeleteSamplers, and the texture's own parameters apply
// again. Both draws must be the texture's colour.
TEST_F(P4aFinalFixScenario, ASamplerObjectDeletedWhileBoundLeavesTheNextDrawIntact) {
if (!Ready()) return;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0);
GLuint sampler = 0;
glGenSamplers(1, &sampler);
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glSamplerParameteri(sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindSampler(0, sampler);
const Image withSampler = DrawSampled(texture);
EXPECT_TRUE(Mostly(withSampler, "red", "the draw through the bound sampler object"));
glDeleteSamplers(1, &sampler); // bound: unbound by the delete, then dies
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("ASamplerObjectDeletedWhileBoundLeavesTheNextDrawIntact", image);
EXPECT_TRUE(Mostly(image, "red", "the draw after the bound sampler object died"));
glBindSampler(0, 0);
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// A second program, in use when it is deleted (GL keeps it alive until it is no longer
// current), then released by a glUseProgram of the fixture's program: it dies there, and
// the draw that follows runs through the survivor.
TEST_F(P4aFinalFixScenario, AProgramDeletedWhileInUseLeavesTheNextDrawIntact) {
if (!Ready()) return;
std::string error;
const GLuint second = CompileProgram(kVS, kFS, &error);
ASSERT_NE(second, 0u) << error;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0);
const Image throughSecond = DrawSampled(texture, second);
EXPECT_TRUE(Mostly(throughSecond, "red", "the draw through the second program"));
glDeleteProgram(second); // current: flagged for deletion, still very much alive
const Image stillCurrent = DrawSampled(texture, second);
EXPECT_TRUE(Mostly(stillCurrent, "red", "the draw through a program flagged for deletion"));
const Image image = DrawSampled(texture); // glUseProgram(m_program): the second dies here
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("AProgramDeletedWhileInUseLeavesTheNextDrawIntact", image);
EXPECT_TRUE(Mostly(image, "red", "the draw after the deleted program was released"));
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// A framebuffer handed to the server BY NAME (a DSA clear emits a Named record, ID-19(c))
// and deleted before the next verb; its attachment lives on and carries the clear.
TEST_F(P4aFinalFixScenario, AFramebufferDeletedAfterADsaClearLeavesItsAttachmentIntact) {
if (!Ready()) return;
const GLuint texture = MakeLevel0(255, 0, 0, /*maxLevel=*/0);
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
glBindFramebuffer(GL_FRAMEBUFFER, 0);
const GLfloat green[4] = {0.0f, 1.0f, 0.0f, 1.0f};
glClearNamedFramebufferfv(fbo, GL_COLOR, 0, green);
glDeleteFramebuffers(1, &fbo); // unbound and named: dies here
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("AFramebufferDeletedAfterADsaClearLeavesItsAttachmentIntact", image);
EXPECT_TRUE(Mostly(image, "green", "the attachment of a framebuffer that died after a DSA clear"));
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// ======================================================================================
// M-A: an image bind after the allocation is a metadata respecify with the hint set
// ======================================================================================
// glTexStorage2D (immutable: no later respecify to ride), a red upload consumed by a draw,
// then a blue upload drained by a verb the texture is not reached by (accepted, standing
// in the applier's pending set), then glBindImageTexture. The bind must reach the record
// as a metadata update - ImageBindableHint 1, the pending upload still standing - and the
// draw after it must show the blue that upload carried through the widened carrier the
// hint schedules.
TEST_F(P4aFinalFixScenario, AnImageBindAfterAllocationReachesTheApplierAsAMetadataRespecify) {
if (!Ready()) return;
SkipUnlessEspryt("M-A's image-bindable hint");
if (IsSkipped()) return;
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
while (glGetError() != GL_NO_ERROR) {
}
if (maxImageUnits < 1) {
GTEST_SKIP() << "no image units";
return;
}
// THE NUMBER ROADMAP OPEN QUESTION 2 ASKS FOR: a texture Espryt allocated BEFORE the
// hint reached it is re-minted image-bindable at the bind and its levels replayed
// from the client's shadow - one remint pull, counted. Arming the counter here is
// what makes it readable without a stats-enabled lane.
unsigned long long pullsBefore = 0;
const bool pullsReadable = PeekPipeStatsTextureRemintPulls(&pullsBefore);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4);
const std::vector<std::uint8_t> red = Solid(4, 255, 0, 0);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, red.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, 0);
const Image before = DrawSampled(texture); // allocated and consumed, NOT image-bindable
EXPECT_TRUE(Mostly(before, "red", "the immutable texture before the image bind"));
PipeTextureResourceRecordPeek record{};
const bool readable = RecordIsReadable(texture, "M-A's image-bindable hint", &record);
if (readable) {
EXPECT_EQ(record.ImageBindableHint, 0u) << "nothing has image-bound this texture yet";
EXPECT_EQ(record.PendingUploads, 0u) << "the red upload was consumed by the draw";
}
// A blue upload, drained by a verb that does not reach T: accepted, unconsumed.
const std::vector<std::uint8_t> blue = Solid(4, 0, 0, 255);
glBindTexture(GL_TEXTURE_2D, texture);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, blue.data());
glBindTexture(GL_TEXTURE_2D, 0);
const Image unrelated = DrawSampled(m_other);
EXPECT_TRUE(Mostly(unrelated, "white", "the unrelated draw"));
if (readable) {
ASSERT_TRUE(PeekPipeTextureResourceRecord(texture, &record));
EXPECT_EQ(record.PendingUploads, 1u) << "the blue upload was not drained into the applier";
}
const unsigned long long serialBeforeBind = record.Serial;
unsigned long long uploadsBeforeBind = 0;
const bool uploadsReadable = PeekPipeStatsTextureUploadEmissions(&uploadsBeforeBind);
// THE TRANSITION. An immutable texture has no storage-defining respecify left, so the
// hint can only arrive as a metadata update (ID-18 M4). Espryt syncs the texture
// eagerly inside glBindImageTexture and the widening re-mints its storage, replaying
// every defined level from the shadow (the remint pull the counter below counts), so
// the standing upload is consumed by that regeneration here and the picture that
// follows is blue whatever the metadata respecify did to the record - the KEPT
// property is proved further down, on a texture no remint stands in front of.
(void)uploadsBeforeBind;
(void)uploadsReadable;
glBindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
if (readable) {
ASSERT_TRUE(PeekPipeTextureResourceRecord(texture, &record));
EXPECT_EQ(record.ImageBindableHint, 1u)
<< "glBindImageTexture did not reach the applier's record as ImageBindableHint";
EXPECT_NE(record.BindMask & (1u << 6), 0u) << "kMGPipeBindShaderImage was not produced";
EXPECT_GT(record.Serial, serialBeforeBind) << "the metadata respecify moved no serial";
}
const Image image = DrawSampled(texture);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
Report("AnImageBindAfterAllocationReachesTheApplierAsAMetadataRespecify", image);
EXPECT_TRUE(Mostly(image, "blue", "the texture after the image bind that followed an unconsumed upload"));
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
unsigned long long pullsAfter = 0;
if (pullsReadable && readable && PeekPipeStatsTextureRemintPulls(&pullsAfter)) {
EXPECT_EQ(pullsAfter, pullsBefore + 1)
<< "the re-mint of a texture allocated before its hint was not counted as a remint pull "
"(trp= on the stats line is ROADMAP open question 2's number)";
}
// THE PREVENTION HALF, measured the other way round: a texture whose hint arrives at
// the bind, BEFORE its first sync, is allocated image-bindable up front and pulls
// nothing - the counter does not move.
GLuint early = 0;
glGenTextures(1, &early);
glBindTexture(GL_TEXTURE_2D, early);
glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, red.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_2D, 0);
glBindImageTexture(0, early, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); // before any sync
const Image earlyImage = DrawSampled(early);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_TRUE(Mostly(earlyImage, "red", "a texture image-bound before its first sync"));
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
unsigned long long pullsEarly = 0;
if (pullsReadable && readable && PeekPipeStatsTextureRemintPulls(&pullsEarly)) {
EXPECT_EQ(pullsEarly, pullsAfter)
<< "a texture whose hint preceded its first sync was still re-minted (the prevention "
"half of the hint did not fire)";
}
// THE METADATA RESPECIFY KEEPS A STANDING UPLOAD, end to end and with no remint in the
// way: `early` is image-bindable already, so a NEW sticky bit reaching it - the
// RENDER_TARGET bit a DSA attachment produces at its setter (a Named record, ID-19(c)),
// with no sync of the texture in between - is a pure metadata update. The blue upload
// drained before it must still stand in the record afterwards (or, if a sync did run,
// have been uploaded rather than dropped) and reach the driver at the next draw.
glBindTexture(GL_TEXTURE_2D, early);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, blue.data());
glBindTexture(GL_TEXTURE_2D, 0);
const Image unrelatedAgain = DrawSampled(m_other);
EXPECT_TRUE(Mostly(unrelatedAgain, "white", "the unrelated draw"));
PipeTextureResourceRecordPeek earlyRecord{};
const bool earlyReadable = PeekPipeTextureResourceRecord(early, &earlyRecord);
if (earlyReadable) {
EXPECT_EQ(earlyRecord.PendingUploads, 1u) << "the blue upload was not drained into the applier";
}
const unsigned long long earlySerialBefore = earlyRecord.Serial;
unsigned long long uploadsBeforeAttach = 0;
const bool uploadsCounted = PeekPipeStatsTextureUploadEmissions(&uploadsBeforeAttach);
GLuint namedFbo = 0;
glCreateFramebuffers(1, &namedFbo);
glNamedFramebufferTexture(namedFbo, GL_COLOR_ATTACHMENT0, early, 0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
if (earlyReadable) {
ASSERT_TRUE(PeekPipeTextureResourceRecord(early, &earlyRecord));
EXPECT_NE(earlyRecord.BindMask & (1u << 7), 0u)
<< "the DSA attachment did not produce kMGPipeBindRenderTarget";
EXPECT_GT(earlyRecord.Serial, earlySerialBefore) << "the mask move reached the record as no respecify";
unsigned long long uploadsAfterAttach = 0;
if (earlyRecord.PendingUploads == 0 && uploadsCounted &&
PeekPipeStatsTextureUploadEmissions(&uploadsAfterAttach)) {
EXPECT_GT(uploadsAfterAttach, uploadsBeforeAttach)
<< "the standing upload vanished from the record without Espryt uploading anything: "
"the metadata respecify dropped it";
} else {
EXPECT_EQ(earlyRecord.PendingUploads, 1u)
<< "the metadata respecify dropped the pending upload standing beside it";
}
}
const Image earlyAfter = DrawSampled(early);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR));
EXPECT_TRUE(Mostly(earlyAfter, "blue", "the upload that stood across a metadata respecify"));
glDeleteFramebuffers(1, &namedFbo);
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
GLuint cleanupEarly = early;
glDeleteTextures(1, &cleanupEarly);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,803 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/P4aSeamAuditScenario.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 FOUR SEAMS THE P4a FABLE SEAM AUDIT PROVED, each pinned by the public-GL sequence
// (or the white-box reading) that was red on the tree the audit read and is green with its fix.
//
// The audit's rule, which every case here is an instance of: EVERY FIELD OF EVERY EMITTED RECORD
// NAMES THE FRONTEND SETTER THAT CHANGES IT, AND THAT SETTER MOVES A COUNTER THE EMITTING BIT'S
// SHUTTER READS. A record whose field has a setter no shutter sees is a stale record with nothing
// to refuse - no census line, no Fatal, a wrong picture or a permanent silent fallback - which is
// why none of the 80 scenarios before this file caught any of the four (Tracker.h carries the
// record-field -> setter -> shutter table this file is the gate for).
//
// F-3 set_framebuffer_state INLINES an attachment's format (D-C1) and a storage redefinition of
// an ATTACHED texture or renderbuffer moved nothing bit 11 read: Espryt's handle arm then
// answered its alpha-widening / snorm-clamp / integer masks from the stale copy. AND THE
// PRE-HANDLE ARM WAS NOT FRESH EITHER, which these cases found on the 0x1ff / 0 / pull
// lanes: a redefinition that keeps the driver id (mutable texture storage regenerated in
// place, a renderbuffer re-storaged in place) moves neither the framebuffer's frontend
// versions nor the backend-id generation the FBO memo reads, so SyncToBackend never
// re-ran and the masks stayed on both arms. The texture half is fixed on both arms OF A
// PUSH BUILD (an in-place regeneration now takes the same generation a re-mint takes -
// compiled under MOBILEGL_PIPE_PUSH because G1 keeps the pull library byte-identical to
// the P4a baseline, so the pull build keeps the pre-P4a hole until the fix lands on dev on
// its own and the texture cases decline by name there); the renderbuffer half only on the
// handle arm, where the resource record carries the re-storage - on the pre-handle arm a
// renderbuffer's twin is only ever reached from inside the FBO walk the memo skips (D-D2's
// documented hole, pre-P4a code), so that case asserts on the handle arm and declines by
// name elsewhere. Three cases, both directions, texture and renderbuffer. DirectGLES only:
// the masks are Espryt's substitution machinery.
// F-1 set_sampler_views is resolved for the PROGRAM IN USE and bit 12's shutter read no program
// input, so a glUseProgram alone never re-emitted it; E's record epoch (the two set serials)
// then kept the program-independent texture sync list from ever rebuilding, and a texture
// bound to an EMPTY slot under one program was never synced for the next. One case, both
// backends, red as a black quad.
// F-2 bit 14's plain-program arm mixed a per-program COUNTER two programs routinely share, so a
// program switch never re-emitted set_shader_images and the window stayed the previous
// program's - and E's SD-4 (a buffer image never reaching the record at all) is the same
// bit through the null -> program transition. One case, white-box, both backends run it.
// F-4 BindCurrentUnitSamplers' record arm looked a CONTENT-addressed CSO handle up in the
// IDENTITY-keyed twin registry: a miss on every draw, hidden because the pre-handle program
// pass bound the same values. One case, white-box: the unit's driver sampler must be the
// CSO's own twin.
//
// A WHITE-BOX READING THAT CANNOT BE TAKEN IS DECLINED BY NAME AND THE CASE CONTINUES with its
// public-GL half (the shape TextureParamsWithoutASamplerViewScenario.cpp argues for): a pull
// build, Magma, or a lane whose mask leaves Espryt's sampler family on its legacy arm has no
// record arm to assert about, and skipping the whole case there would delete the public-GL
// verdict those lanes carry. Every decline is printed and RecordProperty'd.
#include <cstdint>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/P4aSeamPeek.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 {
constexpr int kSize = 16;
constexpr int kInset = 2;
// No attributes: the quad's corners come from gl_VertexID, so a bare VAO is all a draw
// needs and no vertex-input state can enter any of the sequences below.
constexpr const char* kQuadVS = R"(#version 330 core
void main() {
vec2 corner = vec2((gl_VertexID & 1) == 0 ? -1.0 : 1.0,
(gl_VertexID & 2) == 0 ? -1.0 : 1.0);
gl_Position = vec4(corner, 0.0, 1.0);
}
)";
constexpr const char* kColorFS = R"(#version 330 core
uniform vec4 uColor;
out vec4 oColor;
void main() { oColor = uColor; }
)";
// texelFetch, so WHICH image the unit holds is the whole answer and no filter, wrap or
// completeness rule can explain a colour away.
constexpr const char* kFetchFS = R"(#version 330 core
uniform sampler2D uTex;
out vec4 oColor;
void main() { oColor = texelFetch(uTex, ivec2(0, 0), 0); }
)";
// texture() at (1.5, 1.5): outside the image on both axes, so the WRAP mode of whichever
// sampler applies - the unit's sampler object or the texture's built-in one - decides
// whether the texel or the border colour comes back.
constexpr const char* kOutsideSampleFS = R"(#version 330 core
uniform sampler2D uTex;
out vec4 oColor;
void main() { oColor = texture(uTex, vec2(1.5, 1.5)); }
)";
// F-2 / SD-4: two compute programs over BUFFER images (the SD-4 shape - the kind E's I2
// flip found never reached the record at all), the second naming one unit more than the
// first, and both image-unit counters equal (layout(binding) assigns the unit at link, so
// neither program ever moves it through glUniform1i).
constexpr const char* kOneBufferImageCS = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 0, r32ui) writeonly uniform uimageBuffer i0;
void main() { imageStore(i0, 0, uvec4(7u, 0u, 0u, 0u)); }
)";
constexpr const char* kTwoBufferImagesCS = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 0, r32ui) readonly uniform uimageBuffer i0;
layout(binding = 1, r32ui) writeonly uniform uimageBuffer i1;
void main() { imageStore(i1, 0, imageLoad(i0, 0) + uvec4(2u, 0u, 0u, 0u)); }
)";
class P4aSeamAuditScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glDisable(GL_BLEND);
glDisable(GL_SCISSOR_TEST);
glDisable(GL_DEPTH_TEST);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
while (glGetError() != GL_NO_ERROR) {
}
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
while (glGetError() != GL_NO_ERROR) {
}
}
// The F-3 cases are about Espryt's four cross-object masks, which are its own
// substitution machinery (three-channel widening, SNORM/UNORM clamp, integer outputs);
// Magma answers the same GL questions on its own terms, so a verdict there would pin
// a coincidence - the same reason SnormAttachment and ThreeChannelAttachment skip.
// Marks the case skipped; the caller tests IsSkipped() and returns (GTEST_SKIP is a
// void statement, so it cannot return the verdict itself).
void SkipUnlessEspryt(const char* what) {
if (Gl().BackendName() == "DirectGLES") return;
GTEST_SKIP() << what << " is a DirectGLES handle-arm seam; backend is " << Gl().BackendName();
}
static void DrawQuad() { glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); }
// One pixel's RGBA as floats, from the currently bound READ framebuffer.
static void ReadPixelFloat(int x, int y, float out[4]) {
out[0] = out[1] = out[2] = out[3] = -1.0f;
glReadPixels(x, y, 1, 1, GL_RGBA, GL_FLOAT, out);
}
// The white-box gate shared by F-2 and F-4: true when Espryt's sampler family is on
// its handle arm in this process, so the applier's unit sets are consumed and an
// assertion about them can only be red for its own reason. Prints the decline.
bool SamplerHandleArmIsLive(const char* what) {
bool live = false;
std::string why;
if (!PeekEsprytSamplerHandleArmIsLive(&live)) {
why = "the reading cannot be taken here (a pull build, Android, or a backend that "
"is not DirectGLES)";
} else if (!live) {
why = "Espryt's sampler family runs its legacy arm in this process "
"(MOBILEGL_PIPE_PUSH leaves bit 11 clear or refuses it)";
}
if (why.empty()) return true;
std::cout << "[ P4aSeamAudit ] white-box reading DECLINED for " << what << ": " << why
<< "; the public-GL half of the case still runs" << std::endl;
RecordProperty("p4a_seam_white_box", "declined");
RecordProperty("p4a_seam_white_box_reason", why);
return false;
}
// A 2x2 RGBA8 texture filled with one colour, NEAREST, single level - complete under
// every rule, so nothing about completeness can enter the F-1 and F-4 sequences.
static GLuint MakeSolidTexture2D(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
std::uint8_t texels[2 * 2 * 4];
for (int i = 0; i < 4; ++i) {
texels[i * 4 + 0] = r;
texels[i * 4 + 1] = g;
texels[i * 4 + 2] = b;
texels[i * 4 + 3] = 255;
}
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
return texture;
}
bool ComputeImagesAreUsable() const {
GLint maxImageUnits = 0;
glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);
GLint maxComputeImageUniforms = 0;
glGetIntegerv(GL_MAX_COMPUTE_IMAGE_UNIFORMS, &maxComputeImageUniforms);
GLint maxBufferSize = 0;
glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE, &maxBufferSize);
while (glGetError() != GL_NO_ERROR) {
}
return maxImageUnits >= 2 && maxComputeImageUniforms >= 2 && maxBufferSize >= 4;
}
static GLuint MakeComputeProgram(const char* source, std::string* outError) {
const GLuint shader = glCreateShader(GL_COMPUTE_SHADER);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint compiled = GL_FALSE;
glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
if (compiled == GL_FALSE) {
char log[2048] = {};
glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log);
*outError = std::string("compute shader did not compile: ") + log;
glDeleteShader(shader);
return 0;
}
const GLuint program = glCreateProgram();
glAttachShader(program, shader);
glLinkProgram(program);
glDeleteShader(shader);
GLint linked = GL_FALSE;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (linked == GL_FALSE) {
char log[2048] = {};
glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log);
*outError = std::string("compute program did not link: ") + log;
glDeleteProgram(program);
return 0;
}
return program;
}
// An R32UI buffer texture over a fresh 4-texel buffer, every texel `fill`.
static GLuint MakeBufferTexture(GLuint* outBuffer, GLuint fill) {
const GLuint texels[4] = {fill, fill, fill, fill};
glGenBuffers(1, outBuffer);
glBindBuffer(GL_TEXTURE_BUFFER, *outBuffer);
glBufferData(GL_TEXTURE_BUFFER, sizeof(texels), texels, GL_DYNAMIC_COPY);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_BUFFER, texture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_R32UI, *outBuffer);
return texture;
}
static GLuint ReadBufferTexel0(GLuint buffer) {
GLuint value = 0xFFFFFFFFu;
glBindBuffer(GL_TEXTURE_BUFFER, buffer);
glGetBufferSubData(GL_TEXTURE_BUFFER, 0, sizeof(value), &value);
return value;
}
GLuint m_vao = 0;
};
// -----------------------------------------------------------------------------------
// F-3: a storage redefinition WHILE ATTACHED reaches the framebuffer record
// -----------------------------------------------------------------------------------
//
// GL_SRGB8 is a format Espryt can only render into through its three-channel widening
// (llvmpipe reports INCOMPLETE_ATTACHMENT for it natively - ThreeChannelAttachmentScenario
// measured the table), so its draw buffer carries the alpha-widened mask: every draw has its
// alpha masked off so the stored alpha stays at the 1.0 a three-channel format implies.
// Redefine the same attached texture as GL_SRGB8_ALPHA8 and the application owns alpha
// again - the mask must clear. On the tree the audit read the record still said SRGB8, the
// handle arm kept masking, and the 0.25 this case draws never reached the storage; on the
// pre-handle arm the twin regenerated the (mutable) storage on the same driver id, nothing
// the FBO memo reads moved, and the masks stayed the same way.
TEST_F(P4aSeamAuditScenario, ATextureRespecifiedWhileAttachedReachesTheFramebufferRecord) {
if (!Ready()) return;
SkipUnlessEspryt("F-3");
if (IsSkipped()) return;
std::string error;
const GLuint program = CompileProgram(kQuadVS, kColorFS, &error);
ASSERT_NE(program, 0u) << error;
const GLint colorLocation = glGetUniformLocation(program, "uColor");
ASSERT_GE(colorLocation, 0);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8, kSize, kSize, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
ASSERT_EQ(FirstGLError(), 0u) << "the SRGB8 texture was refused";
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "an SRGB8 colour attachment must be complete (natively or through the widening)";
glViewport(0, 0, kSize, kSize);
glUseProgram(program);
glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f);
// Phase 1: the three-channel format. Whatever the draw writes, GL reports alpha 1.0.
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
DrawQuad();
float pixel[4];
glReadBuffer(GL_COLOR_ATTACHMENT0);
ReadPixelFloat(kSize / 2, kSize / 2, pixel);
EXPECT_NEAR(pixel[3], 1.0f, 0.02f) << "a three-channel attachment reports alpha 1.0";
// Phase 2: THE RESPECIFY, while attached, with no re-attach and no rebind of the FBO.
// The only thing that moves between the two draws is the texture's storage.
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, kSize, kSize, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
ASSERT_EQ(FirstGLError(), 0u) << "the respecify to SRGB8_ALPHA8 was refused";
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
DrawQuad();
ReadPixelFloat(kSize / 2, kSize / 2, pixel);
EXPECT_NEAR(pixel[1], 1.0f, 0.05f) << "the draw did not land at all";
// PUSH BUILDS ONLY, EVERY ARM OF THEM. The pre-handle half of the fix (an in-place
// regeneration takes the backend-id generation a re-mint takes) is Espryt code the
// pull build would share, and G1 keeps the pull library byte-identical to the P4a
// baseline - so it is compiled under MOBILEGL_PIPE_PUSH and the pull build keeps the
// pre-P4a hole until the same lines land on dev on their own. The peek returns true
// exactly where it could look, which for a case that already skipped off Espryt means
// "a push build"; what it writes (is the handle arm live) does not matter here.
bool framebufferArmLive = false;
if (PeekEsprytFramebufferHandleArmIsLive(&framebufferArmLive)) {
EXPECT_NEAR(pixel[3], 0.25f, 0.02f)
<< "the draw's alpha never reached a four-channel attachment: the framebuffer record "
"(handle arm) or the FBO twin's memo (pre-handle arm) still describes the "
"three-channel storage the texture was attached with, so alpha stayed masked off (F-3)";
} else {
std::cout << "[ P4aSeamAudit ] texture respecify verdict DECLINED on the pull build (the "
"in-place regeneration bump is push-only by G1); alpha read "
<< pixel[3] << std::endl;
RecordProperty("p4a_seam_white_box", "declined");
RecordProperty("p4a_seam_white_box_reason", "texture respecify: pull build (G1)");
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &texture);
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// The renderbuffer twin. A renderbuffer's three storage setters bump no version at all:
// D-D2 closed the RESOURCE record by emitting from the entry point and left the framebuffer
// record - and with it the masks - describing the storage it was attached with.
TEST_F(P4aSeamAuditScenario, ARenderbufferRestoragedWhileAttachedReachesTheFramebufferRecord) {
if (!Ready()) return;
SkipUnlessEspryt("F-3");
if (IsSkipped()) return;
std::string error;
const GLuint program = CompileProgram(kQuadVS, kColorFS, &error);
ASSERT_NE(program, 0u) << error;
const GLint colorLocation = glGetUniformLocation(program, "uColor");
ASSERT_GE(colorLocation, 0);
GLuint renderbuffer = 0;
glGenRenderbuffers(1, &renderbuffer);
glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_SRGB8, kSize, kSize);
ASSERT_EQ(FirstGLError(), 0u) << "the SRGB8 renderbuffer was refused";
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE))
<< "an SRGB8 renderbuffer attachment must be complete (natively or through the widening)";
glViewport(0, 0, kSize, kSize);
glUseProgram(program);
glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
DrawQuad();
float pixel[4];
glReadBuffer(GL_COLOR_ATTACHMENT0);
ReadPixelFloat(kSize / 2, kSize / 2, pixel);
EXPECT_NEAR(pixel[3], 1.0f, 0.02f) << "a three-channel attachment reports alpha 1.0";
// THE RE-STORAGE, while attached.
glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer);
glRenderbufferStorage(GL_RENDERBUFFER, GL_SRGB8_ALPHA8, kSize, kSize);
ASSERT_EQ(FirstGLError(), 0u) << "the re-storage to SRGB8_ALPHA8 was refused";
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
DrawQuad();
ReadPixelFloat(kSize / 2, kSize / 2, pixel);
EXPECT_NEAR(pixel[1], 1.0f, 0.05f) << "the draw did not land at all";
// THE HANDLE ARM ONLY. On the pre-handle arm a renderbuffer's twin is reached only from
// inside the FBO walk, and nothing that walk's memo reads moves on glRenderbufferStorage
// - the frontend setters bump no version (D-D2), no framebuffer version sees them, and
// the twin that would bump the backend generation is exactly what the memo skips. That
// is pre-P4a code and D-D2's documented hole; the resource record is what closes it,
// so the verdict is taken where the record is consumed and declined by name elsewhere
// (measured: alpha 1.0 on the pull build and at 0x1ff / 0, the mask of the storage the
// renderbuffer was attached with).
bool framebufferArmLive = false;
if (PeekEsprytFramebufferHandleArmIsLive(&framebufferArmLive) && framebufferArmLive) {
EXPECT_NEAR(pixel[3], 0.25f, 0.02f)
<< "the draw's alpha never reached the four-channel renderbuffer: the framebuffer "
"record still describes the storage it was attached with (F-3)";
} else {
std::cout << "[ P4aSeamAudit ] renderbuffer re-storage verdict DECLINED on the pre-handle arm "
"(D-D2's documented hole: no frontend version and no backend generation moves on a "
"renderbuffer re-storage until the FBO walk the memo skips); alpha read "
<< pixel[3] << std::endl;
RecordProperty("p4a_seam_white_box", "declined");
RecordProperty("p4a_seam_white_box_reason", "renderbuffer re-storage: pre-handle arm (D-D2)");
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteRenderbuffers(1, &renderbuffer);
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// The mirror direction, four channels -> three, and it needs the driver to READ the stored
// alpha because the readback fixup (which consults the frontend) would hide it: after the
// respecify to SRGB8 the widening discipline has to hold - the clear puts 1.0 into the
// carrier's alpha and the draw is masked away from it - so a GL_DST_ALPHA blend of white sees
// 1.0. On a stale record the draw wrote its 0.25 into the carrier and the blend saw that.
TEST_F(P4aSeamAuditScenario, ATextureRespecifiedToThreeChannelsWhileAttachedReachesTheFramebufferRecord) {
if (!Ready()) return;
SkipUnlessEspryt("F-3");
if (IsSkipped()) return;
std::string error;
const GLuint program = CompileProgram(kQuadVS, kColorFS, &error);
ASSERT_NE(program, 0u) << error;
const GLint colorLocation = glGetUniformLocation(program, "uColor");
ASSERT_GE(colorLocation, 0);
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8_ALPHA8, kSize, kSize, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
ASSERT_EQ(FirstGLError(), 0u) << "the SRGB8_ALPHA8 texture was refused";
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
glViewport(0, 0, kSize, kSize);
glUseProgram(program);
// Phase 1: four channels, the application owns alpha.
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f);
DrawQuad();
float pixel[4];
glReadBuffer(GL_COLOR_ATTACHMENT0);
ReadPixelFloat(kSize / 2, kSize / 2, pixel);
EXPECT_NEAR(pixel[3], 0.25f, 0.02f) << "a four-channel attachment stores the draw's alpha";
// Phase 2: THE RESPECIFY to three channels, while attached.
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB8, kSize, kSize, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr);
ASSERT_EQ(FirstGLError(), 0u) << "the respecify to SRGB8 was refused";
ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), static_cast<GLenum>(GL_FRAMEBUFFER_COMPLETE));
glDisable(GL_BLEND);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glUniform4f(colorLocation, 0.0f, 1.0f, 0.0f, 0.25f);
DrawQuad();
// dst = stored alpha; src factor GL_DST_ALPHA, dst factor GL_ZERO, source white =>
// the colour becomes (storedAlpha, storedAlpha, storedAlpha) - ThreeChannelAttachment's
// own probe, which nothing on the readback path can doctor.
glEnable(GL_BLEND);
glBlendFunc(GL_DST_ALPHA, GL_ZERO);
glUniform4f(colorLocation, 1.0f, 1.0f, 1.0f, 1.0f);
DrawQuad();
glDisable(GL_BLEND);
ReadPixelFloat(kSize / 2, kSize / 2, pixel);
// PUSH BUILDS ONLY, EVERY ARM OF THEM (the mirror). The pre-handle half of the fix (an in-place
// regeneration takes the backend-id generation a re-mint takes) is Espryt code the
// pull build would share, and G1 keeps the pull library byte-identical to the P4a
// baseline - so it is compiled under MOBILEGL_PIPE_PUSH and the pull build keeps the
// pre-P4a hole until the same lines land on dev on their own. The peek returns true
// exactly where it could look, which for a case that already skipped off Espryt means
// "a push build"; what it writes (is the handle arm live) does not matter here.
bool framebufferArmLive = false;
if (PeekEsprytFramebufferHandleArmIsLive(&framebufferArmLive)) {
EXPECT_NEAR(pixel[0], 1.0f, 0.05f)
<< "GL_DST_ALPHA read the stored alpha of a three-channel attachment and it was not "
"1.0: the framebuffer record (handle arm) or the FBO twin's memo (pre-handle arm) "
"still describes the four-channel storage the texture was attached with, so the "
"draw was let write alpha (F-3, mirror)";
} else {
std::cout << "[ P4aSeamAudit ] three-channel respecify verdict DECLINED on the pull build (the "
"in-place regeneration bump is push-only by G1); red read "
<< pixel[0] << std::endl;
RecordProperty("p4a_seam_white_box", "declined");
RecordProperty("p4a_seam_white_box_reason", "three-channel respecify: pull build (G1)");
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &texture);
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// -----------------------------------------------------------------------------------
// F-1 / F-1b: a program switch re-resolves the view set, and the texture sync list with it
// -----------------------------------------------------------------------------------
//
// The sequence the audit named, and every step of it is ordinary: two programs sampling two
// different units, a texture bound to a unit's EMPTY 2D slot - the unit was already touched
// through another target, so the high-water mark does not move - while a program that does
// not sample it is in use, then the switch to the one that does. Nothing between the two
// draws touches a parameter, a level or a populated slot, which is exactly what leaves the
// record epoch - and the program-independent texture sync list keyed on it - unmoved on
// the tree the audit read: the second program sampled an unbound unit and drew black.
TEST_F(P4aSeamAuditScenario, ATextureBoundToAnEmptySlotUnderOneProgramIsSampledByTheNext) {
if (!Ready()) return;
std::string error;
const GLuint first = CompileProgram(kQuadVS, kFetchFS, &error);
ASSERT_NE(first, 0u) << error;
const GLuint second = CompileProgram(kQuadVS, kFetchFS, &error);
ASSERT_NE(second, 0u) << error;
glUseProgram(first);
glUniform1i(glGetUniformLocation(first, "uTex"), 0);
glUseProgram(second);
glUniform1i(glGetUniformLocation(second, "uTex"), 1);
glUseProgram(0);
// Every texture exists, complete, with its parameters set, BEFORE the first draw: a
// parameter or a level defined between the two draws would move the sampling-resolution
// generation and rescue the list by accident.
const GLuint red = MakeSolidTexture2D(255, 0, 0);
const GLuint green = MakeSolidTexture2D(0, 255, 0);
GLuint touch3D = 0;
glGenTextures(1, &touch3D);
glBindTexture(GL_TEXTURE_3D, touch3D);
const std::uint8_t blue[2 * 2 * 2 * 4] = {0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255,
0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255};
glTexImage3D(GL_TEXTURE_3D, 0, GL_RGBA8, 2, 2, 2, 0, GL_RGBA, GL_UNSIGNED_BYTE, blue);
glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glBindTexture(GL_TEXTURE_3D, 0);
glBindTexture(GL_TEXTURE_2D, 0);
ASSERT_EQ(FirstGLError(), 0u) << "texture setup left a GL error behind";
ColorFbo target = MakeColorFbo(kSize, kSize);
ASSERT_NE(target.fbo, 0u);
BindFbo(target);
glBindVertexArray(m_vao);
// Unit 1 is TOUCHED through its 3D slot; its 2D slot stays empty. Unit 0 holds red.
// The first program is in use BEFORE the first verb (the clear), so the very first
// view set that goes out is already resolved for it - measured: with no program in
// use at the clear the first set is [null, null], and the bind below then re-resolves
// to [red, null], a DIFFERENT set that moves the serial and rescues the case by
// accident.
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_3D, touch3D);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, red);
glUseProgram(first);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
DrawQuad();
// THE BIND ONTO THE EMPTY SLOT, under a program that does not sample unit 1, and a
// draw with THAT program so the bind's own re-resolution of the view set happens under
// it (the bind generation fires bit 12 at the next verb; a switch inside the same verb
// gap would let that fire resolve under the second program by accident) ...
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, green);
glActiveTexture(GL_TEXTURE0);
DrawQuad();
// ... and THE SWITCH to the one that does sample it. No other state moves.
glUseProgram(second);
DrawQuad();
EXPECT_EQ(FirstGLError(), 0u) << "the two draws left a GL error behind";
const Image image = ReadPixels(kSize, kSize);
ASSERT_FALSE(image.Empty());
EXPECT_TRUE(RegionIsMostly(image, kInset, kSize - 1 - kInset, kInset, kSize - 1 - kInset, "green", 0.0,
"the draw after the program switch"))
<< "black means the second program sampled an unbound unit: the texture bound to the "
"empty slot was never synced because the view set - and E's record epoch with it - "
"did not move on the program switch (F-1 / F-1b); red means the first program's "
"set was still in force";
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, 0);
glBindTexture(GL_TEXTURE_3D, 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, 0);
glUseProgram(0);
DestroyColorFbo(target);
glDeleteTextures(1, &red);
glDeleteTextures(1, &green);
glDeleteTextures(1, &touch3D);
glDeleteProgram(first);
glDeleteProgram(second);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// -----------------------------------------------------------------------------------
// F-2 / SD-4: the image window follows the program, through buffer images
// -----------------------------------------------------------------------------------
//
// Public-GL half: both dispatches store what they should (every arm passes this - the
// server's window/high-water union takes the pre-handle bind for a unit the record does
// not cover, which is exactly why the seam was silent). White-box half, on Espryt's handle
// arm: after the first dispatch set_shader_images must have arrived with a window of ONE
// unit (SD-4: on the tree the audit read a buffer image never reached the record at all -
// the null -> program transition moved nothing bit 14 read), and after the switch to the
// program naming two units the window must be TWO (F-2: the two programs' image-unit
// counters are equal, so the switch alone moved nothing either).
TEST_F(P4aSeamAuditScenario, AProgramSwitchWithEqualImageUnitCountersMovesTheImageWindow) {
if (!Ready()) return;
if (!ComputeImagesAreUsable()) GTEST_SKIP() << "no compute image units / buffer textures on this host";
std::string error;
const GLuint one = MakeComputeProgram(kOneBufferImageCS, &error);
ASSERT_NE(one, 0u) << error;
const GLuint two = MakeComputeProgram(kTwoBufferImagesCS, &error);
ASSERT_NE(two, 0u) << error;
GLuint buffer0 = 0;
GLuint buffer1 = 0;
const GLuint image0 = MakeBufferTexture(&buffer0, 0u);
const GLuint image1 = MakeBufferTexture(&buffer1, 0u);
ASSERT_EQ(FirstGLError(), 0u) << "buffer texture setup left a GL error behind";
// Both units bound BEFORE any dispatch, so the bind generation does not move between
// the two dispatches and the only thing that changes is the program in use.
glBindImageTexture(0, image0, 0, GL_FALSE, 0, GL_READ_WRITE, GL_R32UI);
glBindImageTexture(1, image1, 0, GL_FALSE, 0, GL_READ_WRITE, GL_R32UI);
ASSERT_EQ(FirstGLError(), 0u) << "binding the buffer images left a GL error behind";
const bool whiteBox = SamplerHandleArmIsLive("F-2 / SD-4");
glUseProgram(one);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << "the first dispatch leaked a GL error";
if (whiteBox) {
PipeShaderImageWindowPeek window{};
ASSERT_TRUE(PeekPipeShaderImageWindow(&window));
EXPECT_EQ(window.Start, 0u);
EXPECT_EQ(window.Count, 1u)
<< "set_shader_images never arrived for a program whose only image is a BUFFER "
"image (SD-4): the null -> program transition moved nothing bit 14 read";
}
glUseProgram(two);
glDispatchCompute(1, 1, 1);
glMemoryBarrier(GL_ALL_BARRIER_BITS);
EXPECT_EQ(FirstGLError(), 0u) << "the second dispatch leaked a GL error";
if (whiteBox) {
PipeShaderImageWindowPeek window{};
ASSERT_TRUE(PeekPipeShaderImageWindow(&window));
EXPECT_EQ(window.Start, 0u);
EXPECT_EQ(window.Count, 2u)
<< "the image window did not follow the program switch: two programs with equal "
"image-unit counters, and bit 14 mixed only the counter (F-2)";
}
EXPECT_EQ(ReadBufferTexel0(buffer0), 7u) << "the first program's store did not land";
EXPECT_EQ(ReadBufferTexel0(buffer1), 9u) << "the second program's store did not land";
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI);
glBindImageTexture(1, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_R32UI);
glBindBuffer(GL_TEXTURE_BUFFER, 0);
glBindTexture(GL_TEXTURE_BUFFER, 0);
glUseProgram(0);
glDeleteProgram(one);
glDeleteProgram(two);
glDeleteTextures(1, &image0);
glDeleteTextures(1, &image1);
glDeleteBuffers(1, &buffer0);
glDeleteBuffers(1, &buffer1);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
// -----------------------------------------------------------------------------------
// F-4: the unit's driver sampler is the CSO's own twin on the handle arm
// -----------------------------------------------------------------------------------
//
// Public-GL half: a glBindSampler'd object whose wrap differs from the texture's built-in
// sampler wins (GL 4.6 core 8.10) - every arm passes this, because the pre-handle program
// pass bound the object through its identity twin. White-box half, on Espryt's handle arm:
// the sampler the unit carries on the driver must be the twin Espryt holds AT THE CSO
// HANDLE bind_sampler_states named for the unit. On the tree the audit read that twin did
// not exist - the handle is content-addressed, the registry's twins were minted off
// lifetime ids - so the record arm bound nothing on every draw.
TEST_F(P4aSeamAuditScenario, ABoundSamplerObjectIsDrivenThroughItsCsoTwinOnTheHandleArm) {
if (!Ready()) return;
std::string error;
const GLuint program = CompileProgram(kQuadVS, kOutsideSampleFS, &error);
ASSERT_NE(program, 0u) << error;
// The texture's built-in sampler REPEATS, so (1.5, 1.5) reads the red texel through it;
// the sampler object CLAMPS TO A WHITE BORDER, so the same coordinate reads white
// through it. White is a Vulkan palette border colour, so Magma needs no extension.
const GLuint red = MakeSolidTexture2D(255, 0, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
GLuint sampler = 0;
glGenSamplers(1, &sampler);
glSamplerParameteri(sampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glSamplerParameteri(sampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
const GLfloat white[4] = {1.0f, 1.0f, 1.0f, 1.0f};
glSamplerParameterfv(sampler, GL_TEXTURE_BORDER_COLOR, white);
ASSERT_EQ(FirstGLError(), 0u) << "sampler setup left a GL error behind";
ColorFbo target = MakeColorFbo(kSize, kSize);
ASSERT_NE(target.fbo, 0u);
BindFbo(target);
glBindVertexArray(m_vao);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, red);
glBindSampler(0, sampler);
glUseProgram(program);
glUniform1i(glGetUniformLocation(program, "uTex"), 0);
ClearTo(0.0f, 0.0f, 1.0f, 1.0f);
DrawQuad();
EXPECT_EQ(FirstGLError(), 0u) << "the draw left a GL error behind";
const Image image = ReadPixels(kSize, kSize);
ASSERT_FALSE(image.Empty());
EXPECT_TRUE(RegionIsMostly(image, kInset, kSize - 1 - kInset, kInset, kSize - 1 - kInset, "white", 0.0,
"the draw through the bound sampler object"))
<< "red means the texture's own REPEAT sampler applied instead of the bound object's "
"CLAMP_TO_BORDER";
if (SamplerHandleArmIsLive("F-4")) {
EsprytUnitSamplerPeek peek{};
ASSERT_TRUE(PeekEsprytUnitSampler(0, sampler, &peek));
std::cout << "[ P4aSeamAudit ] white-box: unit 0 driver sampler " << peek.BoundSamplerId
<< ", bind_sampler_states handle {" << peek.CsoHandleSlot << ", " << peek.CsoHandleGen
<< "} inside window " << (peek.UnitInsideWindow ? "yes" : "no") << ", CSO twin "
<< peek.CsoTwinSamplerId << ", identity twin " << peek.IdentityTwinSamplerId << std::endl;
EXPECT_TRUE(peek.UnitInsideWindow) << "bind_sampler_states did not describe unit 0";
EXPECT_NE(peek.CsoHandleSlot, 0u) << "bind_sampler_states names no CSO for a unit that carries "
"a sampler object";
EXPECT_NE(peek.CsoTwinSamplerId, 0u)
<< "Espryt holds no twin at the CSO handle bind_sampler_states named: the record arm's "
"lookup went to the identity-keyed registry with a content-addressed handle and "
"could never hit (F-4)";
EXPECT_EQ(peek.BoundSamplerId, peek.CsoTwinSamplerId)
<< "the driver sampler on unit 0 is not the CSO's twin, so it was put there by the "
"pre-handle program pass and not by the record arm (F-4)";
EXPECT_EQ(peek.IdentityTwinSamplerId, 0u)
<< "an identity-keyed twin was minted for the sampler object on the handle arm: the "
"pre-handle pass is still the one doing the binding";
}
glBindSampler(0, 0);
glBindTexture(GL_TEXTURE_2D, 0);
glUseProgram(0);
DestroyColorFbo(target);
glDeleteSamplers(1, &sampler);
glDeleteTextures(1, &red);
glDeleteProgram(program);
EXPECT_EQ(FirstGLError(), 0u) << GLErrorName(FirstGLError());
}
} // namespace
} // 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
@@ -0,0 +1,775 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TextureParamsWithoutASamplerViewScenario.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 - A TEXTURE'S PARAMETERS TAKE EFFECT EVEN WHEN IT HAS NO SAMPLER VIEW (gate G9), the
// one scenario ROADMAP.md:20 names by hand and requires to be RED before P4a lands.
//
// THE DESIGN STATEMENT IT TESTS, ARCHITECTURE.md:100 (D10), verbatim: "SetTextureParams 按资源寻址、
// 与 sampler view 分开(D10):只作 FBO attachment / image 单元 / glCopyImageSubData 端点的纹理没有
// sampler view,但 Espryt 对 attachment 也同步纹理参数,且 RequireImageBindableStorage 需要在前端参数
// 版本不动时强制重同步." A texture that is only an attachment, only an image-unit binding or only a
// copy endpoint has NO sampler view at all - so a design that carried texture parameters on the
// view would silently drop them for exactly those textures. P4a addresses set_texture_params by
// RESOURCE, independently of any binding, which is what makes the record exist for every texture
// the moment its parameters move.
//
// THE FOUR CASES, and BRIEF-P4A.md D-E3 is where their expected verdicts come from:
//
// AnAttachmentOnlyTexturesSwizzleReachesTheDriver green today, green after
// AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver D-E3 says RED today
// AnImageUnitOnlyTexturesSwizzleSurvivesARequireImageBindableStorageRemint green today, green after
// ACopyImageEndpointOnlyTexturesParamsReachTheDriver green today, green after
//
// [MEASURED, AND IT DOES NOT MATCH D-E3] All four are GREEN on the P4a contract commit (08192d72),
// on llvmpipe, in the push build. The second one is green FOR A REASON THAT IS ITSELF THE FINDING,
// and it is written here rather than in a review comment because the next person to try to make it
// red needs to know why they cannot:
//
// A texture parameter's only public-GL observable is a SAMPLE - nothing about an attachment, an
// image binding or a copy endpoint reads a swizzle or an aspect mode - and a sample puts the
// texture on SyncNeccessaryTextures' UNIT list. That walk calls SyncTextureParamsToBackend for
// every entry whose IsDrawSyncClean is false (DirectGLES.cpp:1896-1898), and IsDrawSyncClean is
// false whenever the frontend's parameter version has moved since the last sync
// (Managers.h:1399-1416, `m_syncedTextureParamsVersion != paramsVersion`). So the very act of
// observing the parameter repairs the state the observation was meant to catch: the gap between
// "the parameter moved on a read-attachment-only texture" and "the driver was told" is REAL, and
// it is closed by the next sampler binding, which is also the only thing that can see it.
//
// What that means for the gate, stated plainly so nobody reads a green here as evidence of
// anything it is not:
//
// * the four cases are a REGRESSION NET around D10, not the evidence for the change. They pin
// the design statement: a texture's parameters take effect however the texture is reached, and
// in particular they would go RED if any of the four reachability paths were ever made to
// depend on the texture having a sampler VIEW - which is exactly the coupling P4a's
// resource-addressed set_texture_params removes and the thing a later phase could reintroduce;
// * the "落地前必须红" artefact ROADMAP.md:20 asks for is NOT produced by the public-GL half
// of this file, and no public-GL integration scenario on a monolith tree can produce it.
// Producing it needs an observation of the DRIVER's texture object taken while the texture is
// still read-attachment-only. ID-19 rules that G9 is therefore a WHITE-BOX assertion, and this
// file now carries the SCENARIO half of it (the unit half is package D's,
// MG_Test/SanityTest.cpp's DirectGLESTextureSync.AnAttachmentOnlyTexturesParametersReachThe
// DriverWithNoSamplerView).
//
// THE WHITE-BOX HALF, and what it adds to the four cases below. Each case, at the point where its
// texture is reachable ONLY its own way and BEFORE the observing sample, takes three readings
// through MG_IntegrationTest/Harness/PipeApplyPeek.h and asserts all three:
//
// (a) the APPLIER holds a set_texture_params record for this texture, at a non-zero ParamsSerial,
// carrying the field the case moved;
// (b) ESPRYT's applied value for the same texture - read back from the DRIVER, through the twin's
// own ES name - is that value ALREADY, not after the first sampler view;
// (c) Espryt holds NO SAMPLER VIEW for this texture yet, which is what turns (b) from "applied"
// into "applied WITHOUT one" and is the whole claim D10 makes.
//
// (c) is the assertion the public-GL half structurally cannot make: making it there would create
// the view. (b) is the half that goes red on a backend that DEFERS - a tree where the parameter
// push is gated on a sampler view existing is green on all four public-GL cases forever, because
// the sample that observes the parameter is also what mints the view and repairs the state.
//
// WHAT THE THREE READINGS DO **NOT** COVER, so the next reader does not over-trust them
// (esprytobj re-review N-9, carried here by request). D's unit probe drives
// SyncTextureParamsToBackend directly, so the only deferral shape IT can see is one INSIDE that
// function. These three run through the real per-frame paths and therefore also see a deferral
// introduced ABOVE it - in SyncNeccessaryTextures, in the attachment walk, or in E's per-unit walk.
// Between them the two halves cover both, and neither covers both alone.
//
// A READING THAT CANNOT BE TAKEN IS DECLINED BY NAME AND THE CASE CONTINUES - it is not a
// GTEST_SKIP, and that is a deliberate departure from the shape the review sketched. These four
// cases are dual-purpose: they are also the END-TO-END regression net around D10, and that net is
// the ONLY thing measuring D10 on exactly the arms where the peek cannot look (the pull build,
// which has no applier at all; the 0x1ff and 0 lanes, where the texture family is switched off;
// Magma, which has no Espryt twin). Skipping the case there would delete the one verdict those
// lanes carry in order to report the absence of a second one. The decline is printed, recorded as
// a test property and named, so a lane that silently stopped taking the reading is visible in the
// log rather than in a count.
//
// WHY THE SECOND ONE IS THE RED, mechanically (scout-espryt-framebuffer.md 2.6, re-opened at the
// base ref). Today a texture's parameters ride on the UNIT BINDING and on the DRAW attachment set:
//
// bound to a sampler unit <= the high-water mark SyncNeccessaryTextures' unit list -> synced
// attachment of the DRAW framebuffer SyncNeccessaryTextures' FBO list -> synced
// attachment of the READ framebuffer ONLY SyncCurrentFBO -> SyncToBackend ->
// SyncAttachmentObject, which calls
// SyncMipmapsToBackend at Managers.cpp:7161
// and NOTHING ELSE -> NOT synced
// bound to an image unit SyncImageTextureBinding ->
// SyncTextureObjectToBackend, and
// RequireImageBindableStorage additionally
// forces m_forceTextureParamsResync -> synced
// a glCopyImageSubData endpoint MakeGLESCopyImageEndpoint ->
// SyncTextureObjectToBackend -> synced
//
// SyncNeccessaryTextures' attachment list reads GetFramebufferBindingSlotChecked(Draw) only
// (DirectGLES.cpp:1944), so a texture that is exclusively a READ attachment gets its STORAGE synced
// and its PARAMETERS never. P4a closes that gap deliberately (D-E3): the record is addressed by
// resource, and Espryt's SyncAttachmentObject applies parameters for ANY attachment, draw or read.
// It is a behaviour change and it is the deliverable, not a drive-by dev fix (ROADMAP.md:98).
//
// HOW EACH CASE OBSERVES "REACHED THE DRIVER", and why the observation is always a LATER SAMPLE.
// A texture parameter is by definition a sampling parameter: nothing about an attachment, an image
// binding or a copy endpoint reads a swizzle or a depth/stencil aspect mode, so the only thing that
// can see one is a sample. Each case therefore does the same three things -
//
// 1. put the texture through ONE of the five reachability paths above, and only that one,
// 2. move a parameter while it is reachable ONLY that way (through the DSA entry points
// glTextureParameteri / glTextureSubImage2D, so no step of the setup ever binds the texture to
// a sampler unit - a bind would put it on the unit list and answer the question by accident),
// 3. sample it once, at the end, and read the colour back.
//
// - and the difference between them is step 1 alone. A case that is red says: the parameter set
// while the texture was reachable only that way did not survive to the sample.
//
// DIRECTGLES ONLY. The gap is Espryt's - it is a statement about SyncAttachmentObject and
// SyncNeccessaryTextures - and P4a does not touch MG_Backend/DirectVulkan (D-Q). Magma answers the
// same GL question through an entirely different path, so a red or a green there would be evidence
// about P7's work rather than about this gate; the cases SKIP on any other backend, naming that.
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>
#include "../Harness/HeadlessGL.h"
#include "../Harness/PipeApplyPeek.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 {
constexpr int kInset = 2;
constexpr int kTextureSize = 4;
constexpr const char* kQuadVS = R"(#version 330 core
in vec2 aPos;
out vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
// Samples texel (0,0) with an explicit fetch: no filtering, no derivatives, no wrap - so
// the colour that comes back is the texel as the driver's swizzle presents it and nothing
// else can move it.
constexpr const char* kFetchFS = R"(#version 330 core
uniform sampler2D uTex;
out vec4 oColor;
void main() { oColor = texelFetch(uTex, ivec2(0, 0), 0); }
)";
// The stencil aspect of a packed depth/stencil texture is an UNSIGNED INTEGER texture, so
// it needs a usampler2D. The case that uses it turns "the stencil value is what was
// cleared" into a colour, because a colour is the only thing this harness can read back.
constexpr const char* kStencilFetchFS = R"(#version 330 core
uniform usampler2D uTex;
uniform uint uExpected;
out vec4 oColor;
void main() {
uint value = texelFetch(uTex, ivec2(0, 0), 0).r;
oColor = (value == uExpected) ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(1.0, 0.0, 0.0, 1.0);
}
)";
struct Vertex {
float x, y;
};
class TextureParamsWithoutASamplerViewScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
if (Gl().BackendName() != "DirectGLES") {
GTEST_SKIP() << "DirectGLES only: this scenario is about Espryt's own reachability "
"table - SyncNeccessaryTextures' attachment list walks the DRAW "
"slot only (DirectGLES.cpp:1944) and SyncAttachmentObject syncs "
"storage and not parameters (Managers.cpp:7161). "
<< Gl().BackendName()
<< " answers the same GL question through a different path, so a "
"verdict here would be evidence about that backend rather than "
"about this gate (P4a touches no DirectVulkan source but "
"MagmaPipeArms.h, D-Q).";
}
std::string error;
m_fetchProgram = CompileProgram(kQuadVS, kFetchFS, &error);
ASSERT_NE(m_fetchProgram, 0u) << error;
static const Vertex quad[6] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f},
{-1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}};
glGenBuffers(1, &m_quadBuffer);
glBindBuffer(GL_ARRAY_BUFFER, m_quadBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr);
glBindVertexArray(0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the scene setup left a GL error behind";
}
void TearDown() override {
if (!Ready() || IsSkipped()) return;
glUseProgram(0);
glBindVertexArray(0);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_quadBuffer != 0) glDeleteBuffers(1, &m_quadBuffer);
if (m_fetchProgram != 0) glDeleteProgram(m_fetchProgram);
}
// Direct State Access is how every setup step below touches a texture, and it is the
// whole reason the cases can claim "this texture never had a sampler view": the classic
// entry points (glTexImage2D, glTexParameteri) all require the texture to be BOUND to a
// unit first, and a bind is exactly what puts it on SyncNeccessaryTextures' unit list.
// A case that used them would sync the parameters through the path it is trying to
// exclude and would be green for the wrong reason, on every tree, forever.
bool DirectStateAccessIsAvailable() {
GLuint probe = 0;
glCreateTextures(GL_TEXTURE_2D, 1, &probe);
const bool ok = FirstGLError() == GLenum(GL_NO_ERROR) && probe != 0;
if (probe != 0) glDeleteTextures(1, &probe);
return ok;
}
void SkipWithoutDirectStateAccess() {
if (!DirectStateAccessIsAvailable()) {
GTEST_SKIP() << "glCreateTextures is not usable here, and every case in this file "
"needs the DSA entry points: the classic ones bind the texture to "
"a unit, which is the reachability path these cases exist to "
"exclude. A case that fell back to them would be green for the "
"wrong reason rather than measuring anything.";
}
}
// A 4x4 RGBA8 texture, one solid colour, created and filled WITHOUT EVER BINDING IT.
GLuint MakeSolidTextureWithoutBinding(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
std::vector<std::uint8_t> texels(kTextureSize * kTextureSize * 4);
for (std::size_t i = 0; i < texels.size(); i += 4) {
texels[i] = r;
texels[i + 1] = g;
texels[i + 2] = b;
texels[i + 3] = 255;
}
GLuint texture = 0;
glCreateTextures(GL_TEXTURE_2D, 1, &texture);
glTextureStorage2D(texture, 1, GL_RGBA8, kTextureSize, kTextureSize);
glTextureSubImage2D(texture, 0, 0, 0, kTextureSize, kTextureSize, GL_RGBA,
GL_UNSIGNED_BYTE, texels.data());
glTextureParameteri(texture, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTextureParameteri(texture, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
return texture;
}
// THE OBSERVATION, shared by every case: bind the texture to a unit for the first time
// in its life and read one texel back through the default framebuffer.
Image SampleAndRead(GLuint program, GLuint texture) {
BindDefaultFramebuffer();
glViewport(0, 0, Gl().Width(), Gl().Height());
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(program);
glUniform1i(glGetUniformLocation(program, "uTex"), 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
glBindTexture(GL_TEXTURE_2D, 0);
return image;
}
::testing::AssertionResult WholeViewportIs(const Image& image, const char* expected,
const std::string& when) {
return RegionIsMostly(image, kInset, image.Width() - kInset, kInset,
image.Height() - kInset, expected, 0.0, when);
}
// R -> ZERO and G -> ONE, so a RED texel samples as GREEN if and only if the swizzle
// reached the driver, and as RED if it did not. Two colours the harness can name, from
// one parameter change, with no third outcome that could be mistaken for either.
void SwizzleRedIntoGreen(GLuint texture) {
glTextureParameteri(texture, GL_TEXTURE_SWIZZLE_R, GL_ZERO);
glTextureParameteri(texture, GL_TEXTURE_SWIZZLE_G, GL_ONE);
glTextureParameteri(texture, GL_TEXTURE_SWIZZLE_B, GL_ZERO);
}
// Which of the two parameters a case moved, and therefore which one the white-box
// reading has to find on both sides of the seam. Two, because they are the two the
// four cases use and because a peek that reported "some parameter" would be green for
// a backend that applied the wrong one.
enum class MovedParameter { Swizzle, DepthStencilMode };
// ------------------------------------------------------------------------------
// G9's WHITE-BOX READING (ID-19). Called by every case at the point where its
// texture is reachable only its own way and BEFORE the observing sample - which is
// the whole of the design, because the sample repairs what it observes.
//
// `expectedSwizzle` is the four GL enums the case set (or left at their defaults);
// `expectedDepthStencilMode` is GL_DEPTH_COMPONENT or GL_STENCIL_INDEX. Both are
// always passed and `moved` says which one is the case's subject, so a reader of a
// failure can see the untouched half beside the moved one.
void TakeTheWhiteBoxReadingBeforeAnySample(GLuint texture, GLenum target,
MovedParameter moved,
const GLint expectedSwizzle[4],
GLint expectedDepthStencilMode,
const char* whatMadeItReachable) {
const char* const movedName =
moved == MovedParameter::Swizzle ? "GL_TEXTURE_SWIZZLE_*"
: "GL_DEPTH_STENCIL_TEXTURE_MODE";
PipeTextureParamsRecordPeek record{};
if (!PeekPipeTextureParamsRecord(static_cast<unsigned>(texture), &record)) {
DeclineTheWhiteBoxReading(
"no set_texture_params record for this texture in the applier. Either "
"there is no applier here (a PULL build: MGPipeApplierState is "
"#if MOBILEGL_PIPE_PUSH), or this lane's MOBILEGL_PIPE_PUSH leaves "
"kMGPipeSubsystemTextureResources (bit 10) clear, or no backend "
"registered MGPipeResourceOps so the client never emitted (c0f). The "
"end-to-end half of this case below is unaffected and still decides it.");
return;
}
// From here the reading WAS taken, so everything is a hard assertion: a record
// that exists and does not carry the parameter is exactly the finding.
EXPECT_NE(record.ParamsSerial, 0u)
<< "the applier holds a resource record for texture " << texture
<< " at handle {" << record.Slot << ", " << record.Gen
<< "} but its ParamsSerial is 0, i.e. NO set_texture_params has ever been "
"applied to it - and this case moved " << movedName << " while the texture "
"was " << whatMadeItReachable
<< ". A parameter change on a texture with no sampler view has to produce a "
"record addressed BY RESOURCE (D10, D-E1); a zero here means the client "
"never emitted one, which is the coupling P4a exists to remove reappearing "
"on the emitter's side of the seam.";
if (moved == MovedParameter::Swizzle) {
for (int channel = 0; channel < 4; ++channel) {
EXPECT_EQ(record.Swizzle[channel], static_cast<int>(expectedSwizzle[channel]))
<< "the applier's set_texture_params record for texture " << texture
<< " carries the wrong swizzle in channel " << channel
<< " (record 0x" << std::hex << record.Swizzle[channel] << ", expected 0x"
<< expectedSwizzle[channel] << std::dec
<< "). The record is what Espryt reads, so a wrong value here is a "
"wrong value everywhere downstream of it.";
}
} else {
EXPECT_EQ(record.DepthStencilMode, static_cast<int>(expectedDepthStencilMode))
<< "the applier's set_texture_params record for texture " << texture
<< " carries GL_DEPTH_STENCIL_TEXTURE_MODE 0x" << std::hex
<< record.DepthStencilMode << ", expected 0x" << expectedDepthStencilMode
<< std::dec << ".";
}
// (c) - and it is checked BEFORE (b) is read, because (b) reads the driver and a
// reader of a failure needs to know the view question was answered on the state
// this case built rather than on anything the peek did.
bool hasSamplerView = true;
if (!PeekEsprytHasSamplerViewForTexture(static_cast<unsigned>(texture),
&hasSamplerView)) {
DeclineTheWhiteBoxReading(
"Espryt holds no twin for this texture, so neither the sampler-view "
"question nor the applied-value one can be asked here. On a backend other "
"than DirectGLES that is the designed state (P4a touches no DirectVulkan "
"source but MagmaPipeArms.h, D-Q).");
return;
}
EXPECT_FALSE(hasSamplerView)
<< "Espryt already holds a SAMPLER VIEW for texture " << texture
<< ", which was " << whatMadeItReachable
<< " and has never been bound to a sampler unit in this case. The whole claim "
"of D10 is that a texture reached this way has no view, so if one exists "
"the reading below cannot separate 'applied by resource' from 'applied "
"through the view' and this case has stopped measuring G9.";
EsprytAppliedTextureParamsPeek applied{};
if (!PeekEsprytAppliedTextureParams(static_cast<unsigned>(texture),
static_cast<unsigned>(target), &applied)) {
DeclineTheWhiteBoxReading(
"Espryt's applied value could not be read back from the driver (no twin, "
"no ES name yet, or a target this peek has no binding query for).");
return;
}
std::cout << "[ TextureParamsWithoutASamplerView ] white-box: texture " << texture
<< " -> applier handle {" << record.Slot << ", " << record.Gen
<< "} paramsSerial " << record.ParamsSerial << ", Espryt ES name "
<< applied.BackendTextureId << ", sampler view: none, " << movedName
<< " applied before any sample" << std::endl;
if (moved == MovedParameter::Swizzle) {
for (int channel = 0; channel < 4; ++channel) {
EXPECT_EQ(applied.Swizzle[channel], static_cast<int>(expectedSwizzle[channel]))
<< "ESPRYT HAS NOT APPLIED THE SWIZZLE YET. Channel " << channel
<< " of the driver texture (ES name " << applied.BackendTextureId
<< ") reads 0x" << std::hex << applied.Swizzle[channel] << ", the "
<< "application set 0x" << expectedSwizzle[channel] << std::dec
<< ", and the applier's record already carries the right value - so "
"the record reached the server and the server has not pushed it. "
"The texture was " << whatMadeItReachable
<< " and has NO sampler view (asserted above), which makes this "
"exactly the deferred-to-first-view shape G9 exists to catch: the "
"sample at the end of this case would repair it, and the "
"end-to-end assertion below would then pass on a driver that was "
"told late. That is the half no public-GL case can see.";
}
} else {
if (!applied.DepthStencilModeIsReadable) {
DeclineTheWhiteBoxReading(
"this driver would not answer glGetTexParameteriv("
"GL_DEPTH_STENCIL_TEXTURE_MODE), so the applied aspect mode cannot be "
"read back. The record half above was still asserted.");
return;
}
EXPECT_EQ(applied.DepthStencilMode, static_cast<int>(expectedDepthStencilMode))
<< "ESPRYT HAS NOT APPLIED THE DEPTH/STENCIL ASPECT MODE YET. The driver "
"texture (ES name " << applied.BackendTextureId << ") reads 0x"
<< std::hex << applied.DepthStencilMode << ", the application set 0x"
<< expectedDepthStencilMode << std::dec
<< ", and the applier's record already carries the right value. The "
"texture was " << whatMadeItReachable
<< " and has no sampler view, so this is D-E3's gap measured directly "
"rather than through a sample that would repair it: a driver left at "
"GL_DEPTH_COMPONENT samples the DEPTH bits where the application asked "
"for stencil.";
}
}
// Printed, recorded and named, never silent - a lane that stopped taking the reading
// must be visible in the log. See this file's header for why it is not a GTEST_SKIP.
void DeclineTheWhiteBoxReading(const std::string& why) {
std::cout << "[ TextureParamsWithoutASamplerView ] white-box reading DECLINED: "
<< why << std::endl;
RecordProperty("g9_white_box", "declined");
RecordProperty("g9_white_box_reason", why.c_str());
}
GLuint m_fetchProgram = 0;
GLuint m_vao = 0;
GLuint m_quadBuffer = 0;
};
// The swizzle SwizzleRedIntoGreen leaves behind, as GL enums: R -> ZERO, G -> ONE,
// B -> ZERO and A untouched at its GL default. Written once here because both the
// applier record and the driver read-back are compared against it.
constexpr GLint kRedIntoGreenSwizzle[4] = {GL_ZERO, GL_ONE, GL_ZERO, GL_ALPHA};
// A texture whose aspect mode was never touched, i.e. the GL initial value - which is
// also what a zeroed MGPTextureParams::DepthStencilMode decodes to (MGPipeTypes.h).
constexpr GLint kUntouchedDepthStencilMode = GL_DEPTH_COMPONENT;
// ...and the identity swizzle, for the case whose subject is the aspect mode: the moved
// half is asserted, and the untouched half is carried so a failure prints both.
constexpr GLint kUntouchedSwizzle[4] = {GL_RED, GL_GREEN, GL_BLUE, GL_ALPHA};
// ------------------------------------------------------------------------------------
// 1. DRAW ATTACHMENT ONLY. Green today (SyncNeccessaryTextures' FBO list walks the draw
// slot and calls SyncTextureObjectToBackend, which syncs parameters) and green after.
// It is the regression net for the half of D-E3 that already works: P4a moves the
// parameter sync from the sync list onto the record, and this case is what says the
// move did not lose the case that used to be covered.
// ------------------------------------------------------------------------------------
TEST_F(TextureParamsWithoutASamplerViewScenario, AnAttachmentOnlyTexturesSwizzleReachesTheDriver) {
if (!Ready()) return;
SkipWithoutDirectStateAccess();
if (IsSkipped()) return;
const GLuint texture = MakeSolidTextureWithoutBinding(255, 0, 0);
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
ASSERT_EQ(glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE));
// The parameter moves while the texture is reachable ONLY as a draw attachment...
SwizzleRedIntoGreen(texture);
// ...and a frame runs with it bound that way, so whatever the draw path syncs, syncs.
glViewport(0, 0, kTextureSize, kTextureSize);
ClearTo(1.0f, 0.0f, 0.0f, 1.0f);
BindDefaultFramebuffer();
Gl().EndFrame();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the draw-attachment frame left a GL error";
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glDeleteFramebuffers(1, &fbo);
// G9's white-box reading, taken here: the texture has been a draw attachment and
// nothing else, and the sample below has not happened yet.
TakeTheWhiteBoxReadingBeforeAnySample(texture, GL_TEXTURE_2D, MovedParameter::Swizzle,
kRedIntoGreenSwizzle, kUntouchedDepthStencilMode,
"an attachment of the DRAW framebuffer and "
"nothing else");
const Image image = SampleAndRead(m_fetchProgram, texture);
EXPECT_TRUE(WholeViewportIs(image, "green",
"a texture that was only ever a DRAW attachment, sampled "
"after its swizzle moved"))
<< "the swizzle set while this texture was reachable only as a draw-framebuffer "
"attachment did not reach the driver: a red texel with R->ZERO, G->ONE must "
"sample as green.";
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// ------------------------------------------------------------------------------------
// 2. READ ATTACHMENT ONLY - THE MANDATORY RED (ROADMAP.md:20, D-E3, G9).
//
// The parameter is GL_DEPTH_STENCIL_TEXTURE_MODE and the texture is a packed
// depth/stencil one, because that is the parameter whose absence is not a mis-filtered
// picture but the WRONG ASPECT: a driver left at the GL default samples the depth bits
// where the application asked for stencil, and the value that comes back is not the
// stencil that was written. RecreateBackendTexture's own comment says exactly this -
// "the mode makes it visible because falling back to the default silently samples the
// wrong aspect rather than merely mis-filtering" (Managers.cpp:4826-4833).
//
// The framebuffer is bound to GL_READ_FRAMEBUFFER and the DRAW target is left on the
// default framebuffer for the whole window, which is the ONE thing that separates this
// case from the one above.
// ------------------------------------------------------------------------------------
TEST_F(TextureParamsWithoutASamplerViewScenario,
AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver) {
if (!Ready()) return;
SkipWithoutDirectStateAccess();
if (IsSkipped()) return;
std::string error;
const GLuint stencilProgram = CompileProgram(kQuadVS, kStencilFetchFS, &error);
ASSERT_NE(stencilProgram, 0u) << error;
GLuint texture = 0;
glCreateTextures(GL_TEXTURE_2D, 1, &texture);
glTextureStorage2D(texture, 1, GL_DEPTH24_STENCIL8, kTextureSize, kTextureSize);
glTextureParameteri(texture, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTextureParameteri(texture, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
if (FirstGLError() != GLenum(GL_NO_ERROR)) {
glDeleteTextures(1, &texture);
glDeleteProgram(stencilProgram);
GTEST_SKIP() << "this driver would not create an immutable DEPTH24_STENCIL8 texture, "
"so there is no packed depth/stencil aspect here to sample and the "
"case cannot answer";
}
// Write a stencil value through the texture AS A DRAW ATTACHMENT once, so that there is
// something in the stencil aspect to read. This is setup, not the window: the window
// below never makes it a draw attachment again.
GLuint fbo = 0;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GLenum(GL_FRAMEBUFFER_COMPLETE)) {
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &texture);
glDeleteProgram(stencilProgram);
GTEST_SKIP() << "a depth-stencil-only framebuffer is incomplete on this driver, so "
"the stencil aspect cannot be written and the case cannot answer";
}
constexpr GLint kStencil = 42;
glViewport(0, 0, kTextureSize, kTextureSize);
glStencilMask(0xFFu);
glClearBufferfi(GL_DEPTH_STENCIL, 0, 0.5f, kStencil);
BindDefaultFramebuffer();
Gl().EndFrame();
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "clearing the stencil aspect left a GL error";
// ---- the window: the texture is reachable ONLY as a READ attachment ----
//
// The DRAW binding is the default framebuffer throughout, so SyncNeccessaryTextures'
// attachment list - which walks the DRAW slot only - never sees this texture, and
// SyncCurrentFBO's read path reaches it through SyncAttachmentObject, which syncs
// storage and not parameters. That is the gap.
glBindFramebuffer(GL_READ_FRAMEBUFFER, fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glTextureParameteri(texture, GL_DEPTH_STENCIL_TEXTURE_MODE, GL_STENCIL_INDEX);
// A frame with the read binding live, and it has to contain a REAL DRAW: SyncCurrentFBO
// and the whole sync-list walk run at the validate point, so a frame that only cleared
// and swapped would never reach the read-side path this case is about.
glViewport(0, 0, Gl().Width(), Gl().Height());
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(m_fetchProgram);
glUniform1i(glGetUniformLocation(m_fetchProgram, "uTex"), 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, 0);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
Gl().EndFrame();
glBindFramebuffer(GL_READ_FRAMEBUFFER, 0);
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the read-attachment frame left a GL error";
// G9's white-box reading, and this is the case it matters most for: the aspect
// mode was set while the texture was reachable ONLY as a read attachment, and the
// observation below is a sample that would repair an unsynced parameter on its way
// to reporting it.
TakeTheWhiteBoxReadingBeforeAnySample(texture, GL_TEXTURE_2D,
MovedParameter::DepthStencilMode,
kUntouchedSwizzle, GL_STENCIL_INDEX,
"an attachment of the READ framebuffer and "
"nothing else");
// ---- the observation ----
BindDefaultFramebuffer();
glViewport(0, 0, Gl().Width(), Gl().Height());
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
glUseProgram(stencilProgram);
glUniform1i(glGetUniformLocation(stencilProgram, "uTex"), 0);
glUniform1ui(glGetUniformLocation(stencilProgram, "uExpected"),
static_cast<GLuint>(kStencil));
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
const Image image = ReadPixels(Gl().Width(), Gl().Height());
Gl().EndFrame();
glBindTexture(GL_TEXTURE_2D, 0);
const GLenum sampleError = FirstGLError();
std::cout << "[ TextureParamsWithoutASamplerView ] read-attachment-only "
"GL_DEPTH_STENCIL_TEXTURE_MODE=GL_STENCIL_INDEX, expecting stencil "
<< kStencil << "; sample GL error 0x" << std::hex << sampleError << std::dec
<< std::endl;
EXPECT_TRUE(WholeViewportIs(image, "green",
"a texture that was only ever a READ attachment, sampled "
"through its stencil aspect"))
<< "GL_DEPTH_STENCIL_TEXTURE_MODE = GL_STENCIL_INDEX was set while this texture was "
"reachable ONLY as an attachment of the READ framebuffer, and the sample did not "
"come back as the stencil value that was cleared into it. The parameter did not "
"reach the driver, and - because the sample itself would have repaired an "
"unsynced parameter (see this file's header: the unit sync list calls "
"SyncTextureParamsToBackend whenever the params version moved) - a red here means "
"something stronger than the D-E3 gap: a reachability path that does not sync "
"parameters AT ALL, i.e. the sampler-view coupling ARCHITECTURE.md:100 (D10) "
"exists to remove. Read the case's own stdout line for the GL error the sample "
"raised before assuming an aspect-mode bug.";
BindDefaultFramebuffer();
glDeleteFramebuffers(1, &fbo);
glDeleteTextures(1, &texture);
glDeleteProgram(stencilProgram);
}
// ------------------------------------------------------------------------------------
// 3. IMAGE UNIT ONLY, across a storage RE-MINT. Green today and green after, and the
// reason it is green is the thing P4a must not lose: glBindImageTexture drives
// RequireImageBindableStorage, which re-mints the storage in a possibly WIDENED carrier
// and sets m_forceTextureParamsResync (Managers.cpp:4787) precisely because the
// frontend's parameter version does not move across that transition. A parameter sync
// gated only on the frontend version would leave the driver at its defaults and sample
// the carrier's surplus channels raw.
// ------------------------------------------------------------------------------------
TEST_F(TextureParamsWithoutASamplerViewScenario,
AnImageUnitOnlyTexturesSwizzleSurvivesARequireImageBindableStorageRemint) {
if (!Ready()) return;
SkipWithoutDirectStateAccess();
if (IsSkipped()) return;
const GLuint texture = MakeSolidTextureWithoutBinding(255, 0, 0);
// The parameter moves while the texture is reachable only as an image-unit binding...
SwizzleRedIntoGreen(texture);
glBindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
const GLenum bindError = FirstGLError();
if (bindError != GLenum(GL_NO_ERROR)) {
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
GTEST_SKIP() << "glBindImageTexture is not usable here (GL error 0x" << std::hex
<< bindError << std::dec
<< "), so the RequireImageBindableStorage transition this case is about "
"cannot be reached";
}
// ...and a frame runs with the image binding live, which is what drives the re-mint.
glViewport(0, 0, Gl().Width(), Gl().Height());
ClearTo(0.0f, 0.0f, 0.0f, 1.0f);
Gl().EndFrame();
glBindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
// G9's white-box reading. RGBA8 is a core image format, so no widening carrier is
// minted and the driver's swizzle is the application's own - the composition
// RecreateBackendTexture applies for a NON-core format would show up here as a
// legitimate difference and this case deliberately does not use one.
TakeTheWhiteBoxReadingBeforeAnySample(texture, GL_TEXTURE_2D, MovedParameter::Swizzle,
kRedIntoGreenSwizzle, kUntouchedDepthStencilMode,
"an image-unit binding and nothing else, across "
"a RequireImageBindableStorage re-mint");
const Image image = SampleAndRead(m_fetchProgram, texture);
EXPECT_TRUE(WholeViewportIs(image, "green",
"a texture that was only ever an image-unit binding, sampled "
"after its swizzle moved and its storage was re-minted"))
<< "the swizzle did not survive the RequireImageBindableStorage re-mint. The re-mint "
"creates a new driver texture at the ES defaults without moving the frontend's "
"parameter version, so the forced resync (Managers.cpp:4787) is the only thing "
"that puts the application's parameters back.";
GLuint cleanup = texture;
glDeleteTextures(1, &cleanup);
}
// ------------------------------------------------------------------------------------
// 4. glCopyImageSubData ENDPOINT ONLY. Green today (MakeGLESCopyImageEndpoint calls
// SyncTextureObjectToBackend, DirectGLES.cpp:7588) and green after. The endpoint is the
// DESTINATION, so the case also proves the copy itself still lands: a swizzled read of
// the copied texel is only meaningful if the texel arrived.
// ------------------------------------------------------------------------------------
TEST_F(TextureParamsWithoutASamplerViewScenario,
ACopyImageEndpointOnlyTexturesParamsReachTheDriver) {
if (!Ready()) return;
SkipWithoutDirectStateAccess();
if (IsSkipped()) return;
const GLuint source = MakeSolidTextureWithoutBinding(255, 0, 0);
const GLuint destination = MakeSolidTextureWithoutBinding(0, 0, 255);
// The parameter moves while the destination is reachable only as a copy endpoint...
SwizzleRedIntoGreen(destination);
glCopyImageSubData(source, GL_TEXTURE_2D, 0, 0, 0, 0, destination, GL_TEXTURE_2D, 0, 0, 0,
0, kTextureSize, kTextureSize, 1);
const GLenum copyError = FirstGLError();
if (copyError != GLenum(GL_NO_ERROR)) {
GLuint cleanup[2] = {source, destination};
glDeleteTextures(2, cleanup);
GTEST_SKIP() << "glCopyImageSubData is not usable here (GL error 0x" << std::hex
<< copyError << std::dec << "), so there is no copy endpoint to be";
}
Gl().EndFrame();
// G9's white-box reading, on the DESTINATION - the endpoint whose parameters moved.
TakeTheWhiteBoxReadingBeforeAnySample(destination, GL_TEXTURE_2D,
MovedParameter::Swizzle, kRedIntoGreenSwizzle,
kUntouchedDepthStencilMode,
"a glCopyImageSubData destination and nothing "
"else");
// ...and the destination now holds the source's RED texel, which the swizzle must turn
// into GREEN when it is finally sampled.
const Image image = SampleAndRead(m_fetchProgram, destination);
EXPECT_TRUE(WholeViewportIs(image, "green",
"a texture that was only ever a glCopyImageSubData endpoint, "
"sampled after its swizzle moved"))
<< "the three readings are distinct and each names its own cause: GREEN is the pass "
"(the swizzle reached the driver); RED means the copy landed and the swizzle did "
"not; BLUE means neither happened, i.e. the destination is still its own original "
"texel and glCopyImageSubData wrote nothing. The swizzle is what turns any texel "
"into (0,1,0), so the colour separates the two failures rather than merging them.";
GLuint cleanup[2] = {source, destination};
glDeleteTextures(2, cleanup);
}
} // namespace
} // namespace MGITest
@@ -0,0 +1,396 @@
// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/TextureUploadShapeScenario.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 TEXTURE UPLOAD SHAPE, RECORDED (BRIEF-P4A.md D-D4). NOT A GATE IN P4a.
//
// WHAT IT IS FOR. SSIM is completely blind to the difference between "one union box" and "N
// separate rects", and that difference is the Mali upload cliff: Mali prices an upload by the
// number of JOBS, and ~100 one-rect sprite jobs against one union box measured +6 ms/frame
// (ARCHITECTURE.md:249-251). Every P4a gate can be green while the emission shape has silently
// inverted, so the shape needs a number - and there are TWO numbers, deliberately:
//
// tex[emit= box= rect= jobs=] the SERVER's count, Espryt's own, which has existed since P2
// emit[ctu=] the CLIENT's count of the same records (CallClass::
// ClientTextureUploadEmissions, minted by the P4a contract commit)
//
// The two agreeing is the whole reason both are printed (D-L). An emission-shape divergence between
// the client that decides the rect model and the server that pays the GPU cost is then a difference
// of two published numbers rather than something only a device can see.
//
// WHY IT IS RECORDED AND NOT GATED, and this is a scope decision rather than a hedge. ROADMAP.md:23
// puts "dirty 归属反转(按存储属主键控的发射游标)" in the P3b/P4b cell: P4a lands the flat drain
// list, and the per-storage-owner emission cursor with view/owner index remapping - which is what
// actually decides the shape for a texture uploaded through a VIEW - is the next phase's. Gating a
// shape the phase has not finished deciding would either pin today's shape as if it were the
// answer, or fail on a change that is the point of the next phase. So P4a BUILDS the scenario (it
// is meaningless without P4a's records) and runs it as a RECORDED comparison; P3b/P4b turns it into
// a gate with the Mali frame-time delta published beside it (D-D4).
//
// WHAT IT THEREFORE ASSERTS, and it is not nothing:
//
// 1. the numbers could be READ AT ALL - the counters exist, the window covers the workload, and
// the workload really uploaded (a run that uploaded nothing would record four zeroes and look
// exactly like a healthy run whose emitter had been switched off);
// 2. the internal ARITHMETIC of the server's own bracket holds: emit == box + rect, and
// jobs >= emit, because a box emission is one job and a rect-list emission is N;
// 3. the client and the server agree on the RECORD COUNT when both are non-zero (ctu == emit).
// Not "when the client is non-zero": on the P4a contract tree the client emits nothing and
// that is a SKIP-shaped observation, not a divergence.
//
// Everything else - which shape each texture took, and whether that is the right shape - is
// RECORDED with RecordProperty and printed, for MEASUREMENTS.md and for the P3b/P4b gate to be
// written against.
//
// THE WORKLOAD is the shape the decision is about: one texture receiving MANY SMALL SCATTERED
// SUB-REGIONS per frame (the sprite-atlas / chunk-renderer shape), and one receiving a single large
// contiguous one. The first is where the box-versus-rect choice is made - MipmapStorage's 96-rect
// cascade and the summedArea*4 >= unionArea*3 union-box fallback - and the second is the control
// that must always be one box whatever the policy is.
//
// DIRECTGLES ONLY. The server-side counters are Espryt's (Managers.cpp:6360-6395); Magma's upload
// path is P7 and contributes nothing to them, so a DirectVulkan lane would record a bracket of
// zeroes and call it a shape.
#include <cstdint>
#include <cstdlib>
#include <iostream>
#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 TextureUploadShape. ctest entry and by nothing else; a harness marker, never
// read by the library.
constexpr const char* kLaneMarker = "MGITEST_TEXTURE_UPLOAD_SHAPE_LANE";
constexpr int kInset = 2;
constexpr int kAtlasSize = 64;
// Enough scattered rects that the box-versus-rect policy has a real decision to make: the
// rect cascade caps at MipmapStorage::kMaxDirtyRects = 96, and the union-box fallback fires
// on summedArea*4 >= unionArea*3, so a handful of rects would take neither branch
// interestingly.
constexpr int kScatteredRects = 40;
constexpr int kRectSize = 2;
constexpr int kFrames = 3;
constexpr const char* kVS = R"(#version 330 core
in vec2 aPos;
out vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
)";
constexpr const char* kFS = R"(#version 330 core
in vec2 vUv;
uniform sampler2D uTex;
out vec4 oColor;
void main() { oColor = texture(uTex, vUv); }
)";
struct Vertex {
float x, y;
};
bool BuildMarkerIsSet(const char* name) {
const char* value = std::getenv(name);
return value != nullptr && value[0] == '1' && value[1] == '\0';
}
class TextureUploadShapeScenario : public ScenarioTest {
protected:
void SetUp() override {
ScenarioTest::SetUp();
if (!Ready()) return;
std::string error;
m_program = CompileProgram(kVS, kFS, &error);
ASSERT_NE(m_program, 0u) << error;
static const Vertex quad[6] = {{-1.0f, -1.0f}, {1.0f, -1.0f}, {1.0f, 1.0f},
{-1.0f, -1.0f}, {1.0f, 1.0f}, {-1.0f, 1.0f}};
glGenBuffers(1, &m_quadBuffer);
glBindBuffer(GL_ARRAY_BUFFER, m_quadBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW);
glGenVertexArrays(1, &m_vao);
glBindVertexArray(m_vao);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr);
glBindVertexArray(0);
}
void TearDown() override {
if (!Ready()) return;
glUseProgram(0);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
if (m_scattered != 0) glDeleteTextures(1, &m_scattered);
if (m_contiguous != 0) glDeleteTextures(1, &m_contiguous);
if (m_vao != 0) glDeleteVertexArrays(1, &m_vao);
if (m_quadBuffer != 0) glDeleteBuffers(1, &m_quadBuffer);
if (m_program != 0) glDeleteProgram(m_program);
}
void SkipUnlessTheLaneIsAssertableHere() {
if (std::getenv(kLaneMarker) == nullptr) {
GTEST_SKIP() << "runs only in its own lane: the TextureUploadShape. ctest entry "
"sets " << kLaneMarker
<< " together with 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 (Gl().BackendName() != "DirectGLES") {
GTEST_SKIP() << "DirectGLES only: the upload-shape counters are Espryt's "
"(Managers.cpp:6360-6395) and " << Gl().BackendName()
<< " contributes nothing to them, so this lane would record a "
"bracket of zeroes and call it a shape.";
return;
}
if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) {
GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH: the client's "
"half of the comparison (CallClass::ClientTextureUploadEmissions, "
"the ctu= field) does not exist there, and a one-sided reading is "
"not the comparison this scenario is for. The entry is registered "
"in every build so that `ctest -L integration-gpu` names the same "
"tests in the pull build and the push build (gate G2).";
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;
}
}
GLuint MakeAtlas(std::uint8_t r, std::uint8_t g, std::uint8_t b) {
std::vector<std::uint8_t> texels(static_cast<std::size_t>(kAtlasSize) * kAtlasSize * 4);
for (std::size_t i = 0; i < texels.size(); i += 4) {
texels[i] = r;
texels[i + 1] = g;
texels[i + 2] = b;
texels[i + 3] = 255;
}
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kAtlasSize, kAtlasSize, 0, GL_RGBA,
GL_UNSIGNED_BYTE, texels.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
return texture;
}
Image DrawSampled(GLuint texture) {
BindDefaultFramebuffer();
glViewport(0, 0, Gl().Width(), Gl().Height());
glUseProgram(m_program);
glUniform1i(glGetUniformLocation(m_program, "uTex"), 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(m_vao);
glDrawArrays(GL_TRIANGLES, 0, 6);
return ReadPixels(Gl().Width(), Gl().Height());
}
GLuint m_program = 0;
GLuint m_vao = 0;
GLuint m_quadBuffer = 0;
GLuint m_scattered = 0;
GLuint m_contiguous = 0;
};
TEST_F(TextureUploadShapeScenario, TheEmittedUploadShapeIsRecordedAndTheTwoSidesAgree) {
if (!Ready()) return;
SkipUnlessTheLaneIsAssertableHere();
if (IsSkipped()) return;
m_scattered = MakeAtlas(0, 255, 0);
m_contiguous = MakeAtlas(0, 255, 0);
// The first draw of each texture uploads its whole level, which is not the shape this
// scenario is about; it happens in the SETUP window, before the one that is read.
DrawSampled(m_scattered);
DrawSampled(m_contiguous);
BindDefaultFramebuffer();
Gl().EndFrame();
// ---- the counted window ----
Image lastScattered;
Image lastContiguous;
for (int frame = 0; frame < kFrames; ++frame) {
// MANY SMALL SCATTERED RECTS: the shape whose box-versus-rect decision is the whole
// subject. They are spread over the atlas on a coarse stride so that their union
// box is most of the texture and their summed area is a small fraction of it -
// which is the input the summedArea*4 >= unionArea*3 fallback is written for.
glBindTexture(GL_TEXTURE_2D, m_scattered);
const std::uint8_t patch[kRectSize * kRectSize * 4] = {
0, 255, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255, 0, 255};
for (int rect = 0; rect < kScatteredRects; ++rect) {
const int x = ((rect * 7) % (kAtlasSize / kRectSize)) * kRectSize;
const int y = ((rect * 5) % (kAtlasSize / kRectSize)) * kRectSize;
glTexSubImage2D(GL_TEXTURE_2D, 0, x, y, kRectSize, kRectSize, GL_RGBA,
GL_UNSIGNED_BYTE, patch);
}
lastScattered = DrawSampled(m_scattered);
// ONE LARGE CONTIGUOUS REGION: the control. Whatever the policy is, this is one
// box and one job, and a reading where it is not says the policy has stopped
// looking at the region at all.
glBindTexture(GL_TEXTURE_2D, m_contiguous);
std::vector<std::uint8_t> band(static_cast<std::size_t>(kAtlasSize) * 8 * 4);
for (std::size_t i = 0; i < band.size(); i += 4) {
band[i] = 0;
band[i + 1] = 255;
band[i + 2] = 0;
band[i + 3] = 255;
}
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kAtlasSize, 8, GL_RGBA, GL_UNSIGNED_BYTE,
band.data());
lastContiguous = DrawSampled(m_contiguous);
}
ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the upload workload left a GL error behind";
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()
<< ", so the shape could not be read at all";
RecordProperty("stats_line", window.line.c_str());
const long long emissions = PipeStatsWindow::CounterOrAbsent(window, "emit");
const long long box = PipeStatsWindow::CounterOrAbsent(window, "box");
const long long rect = PipeStatsWindow::CounterOrAbsent(window, "rect");
const long long jobs = PipeStatsWindow::CounterOrAbsent(window, "jobs");
const long long clientEmissions = PipeStatsWindow::CounterOrAbsent(window, "ctu");
ASSERT_GE(emissions, 0) << "the summary line carries no tex[emit=]: " << window.line;
ASSERT_GE(box, 0) << "no box=: " << window.line;
ASSERT_GE(rect, 0) << "no rect=: " << window.line;
ASSERT_GE(jobs, 0) << "no jobs=: " << window.line;
// THE RECORD. This is the deliverable of this scenario in P4a: a number, in the ctest
// XML and in the log, for MEASUREMENTS.md and for the P3b/P4b gate to be written
// against. Printed as well as recorded, because a RecordProperty is invisible in a
// console run.
std::cout << "[ TextureUploadShape ] backend=" << Gl().BackendName() << " frames=" << kFrames
<< " scattered_rects_per_frame=" << kScatteredRects
<< " server[emit=" << emissions << " box=" << box << " rect=" << rect
<< " jobs=" << jobs << "] client[ctu=" << clientEmissions << "]" << std::endl;
RecordProperty("server_emissions", static_cast<int>(emissions));
RecordProperty("server_box_emissions", static_cast<int>(box));
RecordProperty("server_rect_emissions", static_cast<int>(rect));
RecordProperty("server_upload_jobs", static_cast<int>(jobs));
RecordProperty("client_emissions", static_cast<int>(clientEmissions));
// 1. the workload really uploaded. Without this the three assertions below are all
// 0 == 0 and a run whose emitter was switched off records the same "healthy" shape
// as one that worked.
ASSERT_GT(emissions, 0)
<< "the server counted no texture upload emission at all over " << kFrames
<< " frames of " << kScatteredRects
<< " sub-regions each plus a contiguous band. Either the uploads never reached the "
"backend or the counter stopped counting; in both cases every shape number below "
"would be a zero that means nothing. "
<< window.line;
// 2. the server bracket's own arithmetic.
EXPECT_EQ(box + rect, emissions)
<< "tex[box=] + tex[rect=] must be tex[emit=]: every emission takes exactly one of "
"the two shapes. " << window.line;
EXPECT_GE(jobs, emissions)
<< "tex[jobs=] must be at least tex[emit=]: a box emission is one driver upload job "
"and a rect-list emission is N. " << window.line;
// 3. the two sides agree, WHEN THERE ARE TWO SIDES - and "there are two sides" is
// answered by the BUILD, not by the number (review F-m7).
//
// ctu= IS ALWAYS PRESENT IN A PUSH BUILD: PipeStats.cpp writes the field whether or
// not anything ever incremented the counter, so `clientEmissions > 0` conflated
// three different trees - "no client emitter exists", "the emitter exists and
// emitted nothing", and "the counter was not published at all" - into one branch
// that asserts nothing and prints a sentence that is only true of the first. Once
// package B's texture emitter lands, an emitter that STOPPED emitting would read
// exactly like no emitter at all and this case would have gone green over it, which
// is the failure mode the whole scenario exists to make impossible.
//
// So the discriminator is MGITEST_PIPE_CLIENT_TEXTURE_UPLOAD_EMITTER_PRESENT, the
// build's own content probe for a MG_Impl/Pipe source that emits
// CallClass::ClientTextureUploadEmissions - the same mechanism as every other arming
// decision in this package - and each side of it asserts something real.
const bool clientEmitterExists =
BuildMarkerIsSet("MGITEST_PIPE_CLIENT_TEXTURE_UPLOAD_EMITTER_PRESENT");
ASSERT_GE(clientEmissions, 0)
<< "the summary line carries no ctu= field at all, in a push build, where PipeStats "
"publishes it unconditionally. The client half of the comparison cannot be read: "
<< window.line;
RecordProperty("client_emitter_present", clientEmitterExists ? 1 : 0);
if (clientEmitterExists) {
EXPECT_GT(clientEmissions, 0)
<< "a MG_Impl/Pipe source emits CallClass::ClientTextureUploadEmissions on this "
"tree, and the SERVER counted " << emissions
<< " texture upload emissions for this workload, but the client counted NONE. An "
"emitter that has stopped emitting reads exactly like no emitter at all in "
"this field, which is why this case asks the build rather than the number. "
<< window.line;
EXPECT_EQ(clientEmissions, emissions)
<< "the CLIENT counted " << clientEmissions
<< " texture upload records and the SERVER counted " << emissions
<< " for the same workload in the same window. The two counting the same records "
"is the entire reason both are published (D-L): a divergence here is an "
"emission-shape divergence that SSIM is blind to and that costs ~+6 ms/frame "
"on Mali when it goes the wrong way. "
<< window.line;
} else {
// Not merely "not asserted": on a tree with no client emitter the counter must be
// ZERO, and a non-zero one would mean the probe is looking for the wrong symbol -
// i.e. that the arming decision above is wrong and every future run of this case
// is mis-armed.
EXPECT_EQ(clientEmissions, 0)
<< "no MG_Impl/Pipe source emits CallClass::ClientTextureUploadEmissions on this "
"tree, yet the client counted " << clientEmissions
<< " of them. Something is incrementing that counter which this build's probe "
"cannot see, so the probe is looking for the wrong symbol and this case's "
"arming decision is unreliable in both directions. " << window.line;
std::cout << "[ TextureUploadShape ] no P4a client emitter has landed on this tree "
"(the build's ClientTextureUploadEmissions probe found none), so this "
"run records the SERVER shape only and pins ctu=0. That is the expected "
"reading on the contract tree and it is not a divergence."
<< std::endl;
RecordProperty("client_side", "absent");
}
// The pixels, so that a recorded shape cannot be the shape of a workload that drew
// nothing.
EXPECT_TRUE(RegionIsMostly(lastScattered, kInset, lastScattered.Width() - kInset, kInset,
lastScattered.Height() - kInset, "green", 0.0,
"the scattered-rect atlas"));
EXPECT_TRUE(RegionIsMostly(lastContiguous, kInset, lastContiguous.Width() - kInset, kInset,
lastContiguous.Height() - kInset, "green", 0.0,
"the contiguous-band atlas"));
}
} // namespace
} // namespace MGITest
+157 -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,124 @@
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.
//
// P4a ADDS SIX ROWS, and the same note applies to every one of them: each is SHAPE-ONLY, each
// lands in PipeFill.cpp's EmittedCallSuppliesTheWholeField FALSE arm, and the decision is
// taken THERE rather than inherited from a row's presence here. The rows and their calls:
//
// GetFramebufferBindingSlot -> SetFramebufferState GetProgramForDraw -> SetDrawProgram
// GetImageTextureBinding -> SetShaderImages GetProgramForDispatch -> SetDispatchProgram
// GetTextureUnitObject -> SetSamplerViews GetMaxTouchedTextureUnit -> SetSamplerViews
//
// Five of the six are the pointer-storage case GetBoundVertexArray already documents: the
// field is a BindingSlot<FramebufferObject>, an ImageTextureBinding, a TextureUnit or a
// SharedPtr<ProgramObject> - frontend heap references - and the calls carry eight-byte
// {slot, gen} handles and resolved descriptors. The applier has no way to produce a pointer
// and P4a deliberately does not give it one; skipping the pull would leave every one of those
// mirrors null on every draw of every push build. What retires those pulls is not a better
// applier, it is the phase where the backend stops reading a frontend object at all.
//
// THE SIXTH IS A DIFFERENT ARGUMENT AND IT IS WORTH WRITING DOWN, because it looks like the
// easy one. GetMaxTouchedTextureUnit is a plain Int, and set_sampler_views' Count IS that
// value plus one (the second merge rule: a high-water mark is directly the count argument).
// But the set is SUPPRESSED on an unchanged content hash and is emitted only when bit 12 fires,
// and bit 12's shutter is Mix(textureContent, GetTextureBindGeneration()) - which does NOT
// move on a redundant re-bind of the object a unit already holds, while the high-water mark
// DOES (see NoteUnitTouched in DirtySurface.def). So the applier's Count can lag the frontend's
// high-water mark by exactly the case the suppressor exists to swallow, and the field keeps
// being pulled. Narrowing that is P3b/P4b's, with the backend debounce it takes over.
//
// FOUR ACCESSORS THAT MAP TO A P4a CALL ARE DELIBERATELY NOT HERE, for GetPixelStoreParameters'
// reason - a row here says "this field is supplied", and for these it would be a half-truth:
// GetActiveTextureUnit - glActiveTexture's selector. set_sampler_views carries the RESOLVED
// per-unit set and no active-unit selector at all; nothing on the wire carries it.
// GetTextureContextId - a context identity the backend keys its own tables on. No call
// carries it and none should: it is the server's question about the client, not state.
// GetTextureBindGeneration / GetSamplingResolutionGeneration - frontend SHUTTERS. What
// replaces them server-side is the applier's own Serial, which is a different value with a
// different owner; claiming the sets supply the generations would make the fill loop skip
// two counters no record carries.
// And GetTextureObject / GetProgramObject are STICKY (see MGP_COVERAGE_STICKY_LIST): they are
// keyed by GL name, they are object lookups rather than verb state, and a forwarded field has
// no storage for an emitted call to supply.
#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(GetFramebufferBindingSlot, SetFramebufferState) \
X(GetImageTextureBinding, SetShaderImages) \
X(GetLineWidth, SetDynamicState) \
X(GetLogicOp, CreateRenderState) \
X(GetMaxTouchedTextureUnit, SetSamplerViews) \
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(GetProgramForDispatch, SetDispatchProgram) \
X(GetProgramForDraw, SetDrawProgram) \
X(GetProvokingVertexMode, CreateRenderState) \
X(GetRenderStateParameters, CreateRenderState) \
X(GetRenderStateParametersVersion, BindRenderState) \
X(GetScissorBox, SetDynamicState) \
X(GetStencilState, CreateRenderState) \
X(GetTextureUnitObject, SetSamplerViews) \
X(GetViewport, SetDynamicState) \
X(GetViewportIndexed, SetDynamicState) \
X(IsCapabilityEnabled, CreateRenderState) \
X(IsCapabilityEnabledIndexed, CreateRenderState)
// clang-format on
+424
View File
@@ -0,0 +1,424 @@
// 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.
//
// THE MUTATOR PREFIX SET WIDENS AT P4a, and what it does NOT gain is the more interesting
// half. `pGLContext->` + Add|Set|Mark|Bump|Allocate|Truncate|Record|Notify|Begin|End could
// not see `UseProgram`, `BindVertexArray`, `BindProgramPipelineObject` or
// `BindTransformFeedbackObject` - four mutators that each move a field P3a or P4a pushes -
// because none of them starts with one of those words. `Use` and `Bind` are added, and the
// complete set the widening surfaces was enumerated by grep at the phase's base ref so it
// cannot surprise anybody: exactly those four names, on seven call sites.
//
// `Create*` and `Pop*` are DELIBERATELY NOT ADDED. They create or destroy objects rather than
// move a pushed field, and each object class's creation and destruction is already answered
// twice over - by its own Mark*ForDeletion row below and by the constructor-time
// resource_create - so adding them would produce rows that restate an answer this file already
// gives, and every one of them would have to be maintained against a mechanism that is not
// theirs. A gate whose rows do not each carry their own question is a gate nobody reads.
//
// 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) \
/* ---- P4a, THE FOUR THE WIDENED PREFIX SET SURFACES. Every one of them moves a field */ \
/* P3a or P4a pushes and none of them was visible to the scan before, because none */ \
/* begins with one of the ten words the pattern matched. */ \
/* UseProgram is bit 6's whole subject: the shutter is */ \
/* Mix(GetCurrentProgram()->GetLifetimeId(), GetLinkVersion()) and glUseProgram is */ \
/* what moves the object it reads through. Two call sites. AND SINCE THE FABLE */ \
/* SEAM ROUND (F-1 / F-2) IT IS BITS 12 AND 14's TOO: set_sampler_views is */ \
/* resolved for the program in use and set_shader_images' window is the highest */ \
/* image unit the program in use names, so both shutters mix the same identity */ \
/* bit 6 reads, and a glUseProgram alone moves all three. Undecided for the same */ \
/* reason as bit 6 (the taint below), marked the same way. */ \
/* BindVertexArray is bit 5's, for the same reason one level down: the shutter mixes */ \
/* the bound VAO's identity with its configuration version, and this is the bind. */ \
/* Three call sites. */ \
X(UseProgram, NEW_SHADER|NEW_SAMPLER_VIEWS|NEW_SHADER_IMAGES) \
X(BindVertexArray, NEW_VERTEX_ELEMENTS) \
/* NOT NEW_SHADER, and the derivation refutes it outright rather than leaving it a */ \
/* judgement: this mutator writes m_boundProgramPipeline (plus the pipeline name table) */ \
/* and bit 6's shutter reads m_currentProgram's lifetime id and link version - disjoint */ \
/* sets, on every path. That is not an oversight in the shutter either: it reads */ \
/* GetCurrentProgram() and DELIBERATELY NOT GetProgramForDraw(), because the tracker */ \
/* must not force a compile just to answer "did the shader move", and flattening a */ \
/* pipeline into its composite is exactly the compile it would force. What a bind moves */ \
/* is which program the validate point will flatten, and that field - */ \
/* GetProgramForDraw - is in the may-read mask of every class that draws and is copied */ \
/* by the residual fill at every verb of those classes, EMITTED-AND-STILL-PULLED like */ \
/* GetBoundVertexArray. So the pull is what holds on every path, and it is the answer. */ \
X(BindProgramPipelineObject, kPulledEveryVerb) \
/* No shutter at all, and none is needed: the transform-feedback binding reaches the */ \
/* backend through GetBoundTransformFeedbackLifetimeId and its siblings, which are in */ \
/* the kDraw and kXfbSpan may-read masks, so the residual fill copies them at every */ \
/* verb of those classes. Narrowing it is P4b's, with set_stream_output_targets. */ \
X(BindTransformFeedbackObject, 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. */ \
/* P4a CLOSES ONE OF THE THREE HOLES ABOVE AND STATES WHY THE OTHER TWO ARE NOT HOLES. */ \
/* MarkProgramForDeletion -> kExplicitDestroy. delete_shader_state exists now and */ \
/* ~ProgramObject emits it through the client-side death helper, in the fixed */ \
/* order: the wire delete first, the backend notice second, the slot free last. A */ \
/* program pipeline COMPOSITE takes the same call on the same helper - the server */ \
/* never learns it is a composite. */ \
/* MarkProgramPipelineForDeletion stays kUnpublishedDestroy, and it is NOT waiting */ \
/* for a later phase: a ProgramPipelineObject has no lifetime id and no wire object */ \
/* at all (its only identity is m_everBound). It never gets a handle, so there is */ \
/* nothing for a delete to name. What its cache's eviction DOES publish is the */ \
/* composite's delete_shader_state, which is the row above. */ \
/* MarkShaderForDeletion stays kUnpublishedDestroy for the same kind of reason: a */ \
/* ShaderObject has no lifetime id and never crosses the boundary - the payload is */ \
/* per-stage SPIR-V plus the reflection archive, not source, and glslang lives */ \
/* entirely on the client. */ \
X(MarkBufferObjectForDeletion, kExplicitDestroy) \
X(MarkFramebufferObjectForDeletion, kExplicitDestroy) \
X(MarkProgramForDeletion, kExplicitDestroy) \
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.
//
// IT WAS EMPTY UNTIL P4a, and it stops being empty for a reason that is a property of the
// SCANNER rather than of the rows. Every entry below is a bit answer that is plainly true -
// glUseProgram is what moves the object bits 6, 12 and 14's shutters read through (the
// program in use; bits 12 and 14 since the fable seam round, F-1 / F-2), and glBindVertexArray
// is what moves the object bit 5's shutter reads through - and the write analysis cannot say
// so, because each of the two mutators reaches, BY NAME, a body that writes a member with no
// m_ prefix:
//
// UseProgram -> DestroyProgramSlot() writes `attachedShaders`
// BindVertexArray -> a call spelled `Bind(` resolves to every body of that name, one of
// which (ImageTextureBinding::Bind) writes `Access`
//
// A call resolved by name to every body of that name is one of the three over-approximations
// this analysis documents about itself, and an unplaceable write TAINTS the body it is in -
// which is the right default, because "it does not write anything the shutter reads" must
// never be claimed about code the script could not read. Widening the taint rule to ignore
// non-m_ writes would weaken the one mechanism that catches a genuine under-fire, so the rows
// are MARKED, with the tool's own reason, rather than the tool being made more permissive.
// Control 21 is what proves every marked row still needs its mark (it reads this list, so a
// row that gains a bit here is counted rather than assumed), and control 18 is what fails the
// moment any of them becomes decidable and the mark outlives its reason.
//
// The ten mutators that reach a tainted body (--check prints the count) all carry a prose
// answer, which no derivation checks; these two mutators are the first that carry a bit answer.
#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X) \
X(UseProgram, NEW_SHADER) \
X(UseProgram, NEW_SAMPLER_VIEWS) \
X(UseProgram, NEW_SHADER_IMAGES) \
X(BindVertexArray, NEW_VERTEX_ELEMENTS)
// 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
+69 -4
View File
@@ -55,10 +55,75 @@ 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;
// P4a's four. FOUR AND NOT ONE, for P3a's reason one level out: a framebuffer path that
// regressed, a texture path that regressed, a sampler path that regressed and a program
// path that regressed are four different findings, and clearing one must not disarm the
// other three.
//
// THREE OF THEM HAVE A DEPENDENCY and it is diagnosed at the first use, never half-run -
// one Resolve<Family>SubsystemArm per family beside the backend's existing
// ResolveResourceSubsystemArm, modelled on the bit-8-requires-bit-7 refusal it already
// ships, and lazy rather than at bring-up because a pre-flight child dying on a signal
// makes a whole lane SKIP green: bit 11 requires bit 10 because
// every MGPBoundView::Texture and MGPImageView::Res names a Texture handle and only bit 10
// puts one in the slot table; bit 9 requires bit 10 because MGPSurface::Res does; and bit
// 10 requires bit 7 because a buffer texture's BufferForTexBuffer names a Buffer handle.
// The mirror pairs (10 without 11, 10 without 9, 7 without 10) are all fine, and are
// stated as such because an unreachable branch that says something different is how the
// reachable one drifts. Bit 12 depends on nothing.
inline constexpr Uint64 kMGPipeSubsystemFramebuffer = 1ull << 9; // set_framebuffer_state
inline constexpr Uint64 kMGPipeSubsystemTextureResources = 1ull << 10; // texture + renderbuffer
// resource_*, set_texture_params
inline constexpr Uint64 kMGPipeSubsystemSamplers = 1ull << 11; // sampler CSO, sampler view,
// the three unit sets
inline constexpr Uint64 kMGPipeSubsystemPrograms = 1ull << 12; // shader CSO, draw/dispatch
// program, global constants
// bits 13..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
// P4a's, and the two above are NOT edited: 0x1ff is P4a's T2 arm and its "everything P3a
// had and nothing of mine" control, exactly as 0x7f was P3a's.
inline constexpr Uint64 kMGPipeSubsystemsMigratedAtP4a = 0x1fffull; // bits 0..12
static_assert(kMGPipeSubsystemsMigratedAtP4a ==
(kMGPipeSubsystemsMigratedAtP3a | kMGPipeSubsystemFramebuffer |
kMGPipeSubsystemTextureResources | kMGPipeSubsystemSamplers |
kMGPipeSubsystemPrograms),
"the P4a phase constant and P4a's four subsystem bits have drifted");
// 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.
+11
View File
@@ -85,6 +85,17 @@ namespace MobileGL::MG_Pipe {
// a pipeline object, and the server never learns it is a composite - it is just another
// ShaderCso. Reserving a band rather than a flag keeps the composite resolver's
// lifetime bookkeeping out of the ordinary program slot allocator.
//
// THE ONE ENTRY POINT INTO THE BAND is MGPipeSlotAllocator::AllocateComposite(lifetimeId)
// (MG_Impl/Pipe/SlotAllocator.h, P4a D-H7). MGPipeSlotAllocator::Allocate REFUSES the band
// for kind ShaderCso, which is what makes "an ordinary program can never be handed a
// composite slot" a property of the allocator rather than of its callers; the band carries
// its own exhaustion assert, so exhausting it is a named Fatal rather than silent slot
// theft from ordinary programs. A composite's slot has TWO independent release paths - the
// pipeline cache's LRU eviction and the composite ProgramObject's own destructor - and
// both go through one client-side death helper (MG_Pipe/PipeMutation.h's
// MGPipeEmitShaderCsoDestroyAndFree), whose second call is a proven no-op because Free
// refuses a slot that is not live at that generation.
inline constexpr Uint32 kMGPipeShaderCsoSlotLimit = 1u << 20;
inline constexpr Uint32 kMGPipeShaderCsoCompositeSlotBase =
kMGPipeShaderCsoSlotLimit - (kMGPipeShaderCsoSlotLimit >> 4);
+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
+560 -36
View File
@@ -145,15 +145,152 @@ namespace MobileGL::MG_Pipe {
static_assert(sizeof(MGPCaps) == sizeof(DynamicBackendParameters) + 8 + 24 + 24,
"MGPCaps gained padding or a member; update the wire format");
// ---------------------------------------------------------------------------------
// MGPResourceDesc's two discriminators (P4a, D-A3 / D-A4)
// ---------------------------------------------------------------------------------
// MGPResourceDesc::Target. P3a minted no enum for this list because it had exactly one
// producer and used the leading member's value (0) for it; P4a's texture family is the
// second producer, so the list is written out here, beside the field, in the order the
// field's own comment already wrote it.
//
// TexRect IS A THIRTEENTH ENUMERATOR AND THE BRIEF'S LIST HAS TWELVE. MobileGL's
// TextureTarget has TextureRectangle (MG_State/GLState/TextureState/TextureEnum.h), the
// table below may not have a `default:` arm, and folding rectangle onto Tex2D would erase
// a distinction the frontend keeps and both backends switch on (Espryt's
// MapToBackendTextureTarget lowers Tex1D the same way and Tex1D still has its own
// enumerator here). It is appended AFTER TexBuffer so every value the design document
// names keeps the number it was given.
enum class MGPipeResourceTarget : Uint8 {
Buffer = 0,
Tex1D,
Tex2D,
Tex3D,
Tex1DArray,
Tex2DArray,
TexCube,
TexCubeArray,
Tex2DMS,
Tex2DMSArray,
Renderbuffer,
TexBuffer,
TexRect,
Count,
};
// P3a's constant, moved here from MG_Impl/Pipe/ResourceTracker.h with the enum: the ack
// predicate at the bottom of this header now names the buffer target explicitly (D-A2) and
// may not reach into MG_Impl to do it. The static_assert is what keeps the two spellings
// from drifting; nothing may open-code either.
inline constexpr Uint8 kMGPipeResourceTargetBuffer =
static_cast<Uint8>(MGPipeResourceTarget::Buffer);
static_assert(kMGPipeResourceTargetBuffer == static_cast<Uint8>(MGPipeResourceTarget::Buffer),
"P3a's kMGPipeResourceTargetBuffer and MGPipeResourceTarget::Buffer have drifted");
// A sentinel the table below returns for a TextureTarget enumerator it does not name. It
// is NOT a legal Target value - it does not fit the field's Uint8 - so an unmapped
// enumerator is a build break at the static_assert rather than a descriptor that quietly
// describes the wrong kind of storage. Exactly kMGPipeBindUnmapped's shape.
inline constexpr Uint32 kMGPipeResourceTargetUnmapped = 0x100u;
// The one table. No `default:` arm on purpose - that is what makes the static_assert
// below able to see an unnamed enumerator, and it is the shape
// MGPipeBindMaskForBufferTarget already uses for BufferTarget.
constexpr Uint32 MGPipeResourceTargetForTextureTarget(MobileGL::TextureTarget target) {
switch (target) {
case MobileGL::TextureTarget::Texture1D:
return static_cast<Uint32>(MGPipeResourceTarget::Tex1D);
case MobileGL::TextureTarget::Texture2D:
return static_cast<Uint32>(MGPipeResourceTarget::Tex2D);
case MobileGL::TextureTarget::Texture3D:
return static_cast<Uint32>(MGPipeResourceTarget::Tex3D);
case MobileGL::TextureTarget::TextureCubeMap:
return static_cast<Uint32>(MGPipeResourceTarget::TexCube);
// Its own enumerator rather than Tex2D: see the enum's comment.
case MobileGL::TextureTarget::TextureRectangle:
return static_cast<Uint32>(MGPipeResourceTarget::TexRect);
case MobileGL::TextureTarget::Texture2DMultisample:
return static_cast<Uint32>(MGPipeResourceTarget::Tex2DMS);
case MobileGL::TextureTarget::TextureBuffer:
return static_cast<Uint32>(MGPipeResourceTarget::TexBuffer);
case MobileGL::TextureTarget::Texture1DArray:
return static_cast<Uint32>(MGPipeResourceTarget::Tex1DArray);
case MobileGL::TextureTarget::Texture2DArray:
return static_cast<Uint32>(MGPipeResourceTarget::Tex2DArray);
case MobileGL::TextureTarget::TextureCubeMapArray:
return static_cast<Uint32>(MGPipeResourceTarget::TexCubeArray);
case MobileGL::TextureTarget::Texture2DMultisampleArray:
return static_cast<Uint32>(MGPipeResourceTarget::Tex2DMSArray);
// NOT TEXTURE TARGETS. Listed rather than defaulted so the completeness assert still
// sees them, and mapped to the sentinel because no descriptor may carry either: the
// count is the enum's bound and Unknown is what an unresolved GL enum becomes.
case MobileGL::TextureTarget::TextureTargetCount:
case MobileGL::TextureTarget::Unknown:
return kMGPipeResourceTargetUnmapped;
}
return kMGPipeResourceTargetUnmapped;
}
constexpr Bool MGPipeEveryTextureTargetIsMapped() {
for (SizeT i = 0; i < static_cast<SizeT>(MobileGL::TextureTarget::TextureTargetCount); ++i) {
if (MGPipeResourceTargetForTextureTarget(static_cast<MobileGL::TextureTarget>(i)) ==
kMGPipeResourceTargetUnmapped) {
return false;
}
}
return true;
}
static_assert(MGPipeEveryTextureTargetIsMapped(),
"a TextureTarget enumerator has no MGPResourceDesc::Target row: add it to "
"MGPipeResourceTargetForTextureTarget, and give it an enumerator of its own "
"rather than folding it onto a neighbour (D-A3)");
static_assert(MGPipeResourceTargetForTextureTarget(MobileGL::TextureTarget::Texture2D) !=
MGPipeResourceTargetForTextureTarget(MobileGL::TextureTarget::TextureRectangle),
"a rectangle texture is not a 2D texture on the wire; both backends switch on "
"the difference");
// MGPResourceDesc::BindMask's twelve bits, in the order the field's comment names them.
//
// THEY LIVED IN MG_Impl/Pipe/ResourceTracker.h THROUGH P3a, with that file's own note
// saying "the integrator moves them beside the field when a second producer appears
// (P4a's texture family)". P4a is that producer: a texture sets kMGPipeBindSampler,
// kMGPipeBindShaderImage, kMGPipeBindRenderTarget and kMGPipeBindDepthStencil, which are
// the four bits nothing set before. The mask is STICKY - ORed, never cleared - and is
// emitted on both resource_create and every resource_respecify.
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 a table rather than from a
// special case at the emission site.
kMGPipeBindElementArray = 1u << 11,
};
// Discriminated resource descriptor: buffers, every texture target and renderbuffers
// share one create/respecify shape (section 4.5.1).
struct MGPResourceDesc {
MGPipeHandle Resource;
Uint8 Target; // Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer | TexBuffer
// MGPipeResourceTarget: Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer |
// TexBuffer | TexRect. Never open-coded; the texture half comes from
// MGPipeResourceTargetForTextureTarget above.
Uint8 Target;
Uint8 StorageKind; // == TextureStorageType (Mipmap | Buffer)
// VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET|
// DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. The ELEMENT_ARRAY bit is the
// D-B7 switch: with kCapNeedsHostIndexBytes set the server mirrors this resource.
// MGPipeBindBit, above: VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|
// SHADER_IMAGE|RENDER_TARGET|DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. The
// ELEMENT_ARRAY bit is the D-B7 switch: with kCapNeedsHostIndexBytes set the server
// mirrors this resource.
Uint16 BindMask;
Uint32 InternalFormat; // already resolved to an uncompressed fallback by the client
Uint32 Width, Height, Depth;
@@ -161,9 +298,16 @@ namespace MobileGL::MG_Pipe {
Uint8 FixedSampleLocations, Immutable;
Uint32 Usage; // BufferUsage
Uint32 StorageFlags; // glBufferStorage flags
Uint8 HasDefinedContent; // false after a NULL-data respecify
Uint8 HasDefinedContent; // false after a NULL-data respecify - STORAGE-DEFINING
Uint8 ImageBindableHint; // client-side everImageBound; pre-emptive allocation
Uint16 Pad0;
// ImageBindableHint and BindMask above are the two METADATA fields the rule exists
// for: a respecify that moves only them - every storage-defining field equal to the
// stored descriptor - is a metadata update, with no reallocation ack and no
// PendingUploads clear. The rule, the full storage-defining field set and the third
// field allowed to differ (GlNameForDiag) are stated beside
// MGPipeResourceRespecifyNeedsAck below (P4a, ID-18 M4), which is where the applier
// and both twins read them from.
// Diagnostics only. A GL name is NEVER an identity, never a memo key and never part
// of a content hash (section 4.2.1). Widened from the plan's two bytes, which
// cannot hold one.
@@ -231,8 +375,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 +394,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);
@@ -281,18 +448,49 @@ namespace MobileGL::MG_Pipe {
MGP_ASSERT_POD(MGPSamplerView, 36);
// Per texture OBJECT, independent of any view.
//
// P4a, D-E1: 32 -> 40 bytes. BuiltinSampler is the SamplerCso carrying the
// SamplerParameters of the SamplerObject every ITextureObject owns
// (TextureState/TextureObject.h's m_sampler, constructed by TextureObjectBase's
// constructor). GL 4.6 core table 23.18 makes filter/wrap/compare/border SAMPLER state,
// and Espryt pushes it with glTexParameter* onto the TEXTURE rather than with
// glBindSampler onto the unit - behaviour P4a preserves exactly. Naming the CSO rather
// than widening this payload with a filter/wrap/border block is what keeps ONE authority
// for one value: SyncTextureParamsToBackend reads this record, SyncBuiltinSamplerToBackend
// reads that CSO's SamplerParameters, and the two pushes stay two pushes.
struct MGPTextureParams {
MGPipeHandle Res;
Uint16 BaseLevel, MaxLevel;
Uint8 Swizzle[4];
Uint8 DepthStencilMode;
MGPipeHandle Res; // 0
// Kind SamplerCso. kMGPipeNullHandle is ILLEGAL - every texture object owns a sampler
// object, so a null here is Fatal{ProtocolCorruption} rather than "no sampler".
MGPipeHandle BuiltinSampler; // 8
Uint16 BaseLevel, MaxLevel; // 16
Uint8 Swizzle[4]; // 20
Uint8 DepthStencilMode; // 24
// Mirrors m_forceTextureParamsResync: the widened-channel carrier needs a swizzle
// override that the frontend params version does not move for.
Uint8 ForceResync;
Uint8 Pad0[2];
Float MinLod, MaxLod, LodBias;
Uint8 ForceResync; // 25
// Mirrors m_forceSamplerResync, which had no wire spelling at all before P4a. What it
// guards is not mis-filtering but an INCOMPLETE texture sampling (0,0,0,1) after a
// driver re-mint, which is why it is a second bit and not folded into ForceResync.
Uint8 SamplerResync; // 26
Uint8 Pad0; // 27
Float MinLod, MaxLod, LodBias; // 28
};
MGP_ASSERT_POD(MGPTextureParams, 32);
MGP_ASSERT_POD(MGPTextureParams, 40);
// MGPTextureParams::DepthStencilMode's two legal values, and the ONLY spelling of them
// (P4a, ID-12 / esprytobj DV-2). The frontend keeps a GLenum - GL_DEPTH_COMPONENT 0x1902,
// GL_STENCIL_INDEX 0x1901 - and a Uint8 cannot hold one, so the aspect is NUMBERED here
// rather than truncated there. The GLenum -> byte helper belongs to the client emitter;
// this header owns the two numbers, so the emitter and both backends cannot disagree.
//
// 0 IS DEPTH, AND THAT IS THE WHOLE REASON FOR THIS ORDER RATHER THAN THE ENUM'S LOW BYTE.
// GL_DEPTH_COMPONENT is the GL initial value of GL_DEPTH_STENCIL_TEXTURE_MODE and a
// texture that never asks for the stencil aspect never emits the call at all, so A ZEROED
// RECORD MUST DECODE TO EXACTLY WHAT AN UNTOUCHED TEXTURE ALREADY HAS. Numbering by the
// low byte would have made depth 0x02 and stencil 0x01 and left zero meaning nothing.
inline constexpr Uint8 kMGPipeDepthStencilModeDepth = 0; // GL_DEPTH_COMPONENT
inline constexpr Uint8 kMGPipeDepthStencilModeStencil = 1; // GL_STENCIL_INDEX
// create_shader_state. The reflection blob is the whole LinkArtifacts + SpirvArtifacts
// archive; P0.5 extracts those types out of ProgramObject.h so a server can
@@ -315,39 +513,208 @@ namespace MobileGL::MG_Pipe {
// set_*
// ---------------------------------------------------------------------------------
// MGPSurface::Kind's three values (P4a, ID-12 / esprytobj DV-4). MGPipeKind is REUSED
// rather than a second three-value enum minted beside it: it already spells Texture and
// Renderbuffer, its None is 0, and a zero-initialised MGPSurface is therefore ALREADY the
// empty attachment point this record describes - {Res = kMGPipeNullHandle, Kind = None}
// and every other field zero. The static_assert is what keeps that true if MGPipeKind is
// ever reordered.
inline constexpr Uint8 kMGPipeSurfaceKindNone = static_cast<Uint8>(MGPipeKind::None);
inline constexpr Uint8 kMGPipeSurfaceKindTexture = static_cast<Uint8>(MGPipeKind::Texture);
inline constexpr Uint8 kMGPipeSurfaceKindRenderbuffer =
static_cast<Uint8>(MGPipeKind::Renderbuffer);
static_assert(kMGPipeSurfaceKindNone == 0,
"a zero-initialised MGPSurface must already be the empty attachment point");
// MGPSurface::TextureTarget for a point that names no texture: the renderbuffer point and
// the empty point both carry it. It is MobileGL::TextureTarget::Unknown, which is -1 and
// therefore 0xFFFF in the field's Uint16 - a value no real target has, so a reader that
// forgets to gate on Kind gets a nonsense target rather than a plausible wrong one.
inline constexpr Uint16 kMGPipeSurfaceNoTextureTarget = 0xFFFF;
static_assert(kMGPipeSurfaceNoTextureTarget ==
static_cast<Uint16>(MobileGL::TextureTarget::Unknown),
"kMGPipeSurfaceNoTextureTarget is TextureTarget::Unknown widened to the "
"field, and MG_State moved Unknown off -1");
// = pipe_surface. internalFormat is INLINE so the four cross-object masks fall out at
// push time with no lookup (section 4.5.6).
struct MGPSurface {
MGPipeHandle Res;
Uint32 InternalFormat;
Uint8 Kind; // Texture | Renderbuffer | None
Uint8 Kind; // kMGPipeSurfaceKind{None,Texture,Renderbuffer}, above
Uint8 Layered;
Uint16 Level;
Uint32 Layer;
Uint16 UploadTarget;
Uint16 Pad0;
Uint16 UploadTarget; // static_cast<Uint16>(MobileGL::TextureUploadTarget)
// P4a, ID-12 / esprytobj DV-5: WAS Pad0, and the size did not move - the two bytes
// were already here. static_cast<Uint16>(MobileGL::TextureTarget), and
// kMGPipeSurfaceNoTextureTarget on every point that is not a texture.
//
// THE FOUR CROSS-OBJECT MASKS ARE WHY IT EXISTS. IsSnormFallbackAttachment,
// IsUnormFallbackAttachment and IsAlphaWidenedColorAttachment all reduce to
// (format, TEXTURE TARGET) - ShouldUseCaveatTextureFormat(format, target) and
// BackendTextureFormatAddsAlpha(format, target) - and no TextureUploadTarget ->
// TextureTarget inverse exists anywhere in the tree, so UploadTarget cannot answer
// them. Without this field D-C1's promise that the inline InternalFormat makes the
// masks "fall out at push time with no lookup" is unkeepable and the backend keeps
// reading the frontend attachment objects.
//
// CONSULTED ONLY WHEN Kind == kMGPipeSurfaceKindTexture. A zero-initialised record
// carries 0, which is TextureTarget::Texture1D and not the sentinel; that is not a
// defect, because such a record is Kind == None and names no texture at all. Gating
// on Kind is the reader's contract.
Uint16 TextureTarget;
};
MGP_ASSERT_POD(MGPSurface, 24);
// P4a, D-C2/D-C3 and ID-19: THE RECORD DESCRIBES A FRAMEBUFFER OBJECT, and Target says
// whether it ALSO moves a binding.
//
// A `Named` record describes the framebuffer object it names (Fbo) and changes NO
// binding. `Draw` / `Read` / `Both` records describe the same object AND set the bound
// handle(s) of the target(s) they name.
//
// The applier therefore keeps records PER FRAMEBUFFER OBJECT, keyed by the handle's slot
// (the generation is checked on lookup and a stale one refuses; a framebuffer has no wire
// lifetime - D-I2, the catalogue has no framebuffer delete - so a successor's record
// simply OVERWRITES the slot), plus the two bound handles. And every DSA entry point that
// hands a framebuffer to the server BY NAME - BlitNamedFramebuffer, the four
// ClearNamedFramebuffer*, and the DSA attachment / draw-buffer / read-buffer setters at
// their validate point - is PRECEDED BY A Named RECORD, so that any framebuffer the
// server is about to receive by name already has one.
//
// That last rule is the phase's main correction, not a nicety. With only the two
// bound-target records, glClearNamedFramebufferfv(fbo) on an unbound fbo made the backend
// mint a fresh driver framebuffer with NO ATTACHMENTS, find no record for it, decline,
// and then issue the clear against it anyway - GL_INVALID_FRAMEBUFFER_OPERATION and
// nothing cleared, where the legacy arm cleared correctly. Writing such an object into
// the bound-target record instead would have been worse: the applier would then claim it
// is bound.
//
// GL has two independent framebuffer bindings and one record carries one Fbo, so a
// Draw/Read pair is two records and one object bound to both targets is one record with
// Both. The draw-buffer array belongs to the OBJECT the record names; it reaches the
// driver's bound draw framebuffer only for a record whose Target is Draw or Both -
// Espryt's own comment records the Minecraft 26.x OIT bug where a READ-only sync landed
// glDrawBuffers on the wrong framebuffer - and a Named record's draw buffers are applied
// when that object is next configured, never to whatever happens to be bound. ReadSurface
// is resolved from THAT framebuffer's own read buffer in EVERY record, Named included,
// which is what makes the read-buffer-shared-FBO defect class unrepresentable rather than
// merely fixed.
//
// Target IS A ContentHash INPUT (the hash covers the whole record), and the emitter's
// suppressor must be keyed by the framebuffer the record names, not by one global slot:
// two different objects' Named records in a row must both go out, and a Named record must
// never be suppressed against the same object's bound record or the reverse.
enum class MGPipeFramebufferTarget : Uint8 {
Draw = 0,
Read = 1,
Both = 2,
// Describes the framebuffer named by Fbo and changes no binding (ID-19). Emitted
// ahead of every DSA entry point that hands that framebuffer over by name.
Named = 3,
Count,
};
// MGPFramebufferState::Color[] and DrawBuffers[] are ONE array width, and it is the wire's
// bound rather than the driver's: GetDynamicParameters().MaxColorAttachments is the raw ES
// cap and is not clamped to 8 on the GLES path, so a driver reporting more would silently
// truncate this record. The framebuffer subsystem bit is REFUSED at its first lookup in
// that case, with one ERROR naming the cap, and the legacy arm runs - the same shape the
// backend's existing bit-8-requires-bit-7 refusal already ships
// (ResolveFramebufferSubsystemArm, beside ResolveResourceSubsystemArm). Widening the
// payload is a wire change nobody has evidence for, and truncating silently is the bug
// class this phase is closing.
inline constexpr Uint32 kMGPipeMaxColorAttachments = 8;
static_assert(kMGPipeMaxColorAttachments == MobileGL::kMGMaxDrawBuffers,
"MGPFramebufferState::Color[] and DrawBuffers[] are one array width");
// ONE RECORD DESCRIBES ONE FRAMEBUFFER OBJECT - the one named by Fbo - and Target says
// whether it also moves a binding (P4a, D-C2 as corrected by ID-19; see
// MGPipeFramebufferTarget above for the failure that forced it).
//
// A Named record describes that object and changes NO binding. Draw / Read / Both records
// describe that object AND set the bound handle(s) of the target(s) they name. The applier
// keeps these records PER FRAMEBUFFER OBJECT, keyed by Fbo's slot (generation checked on
// lookup; a framebuffer has no wire lifetime - D-I2 - so a successor's record simply
// overwrites the slot), plus the two bound handles; every DSA entry point that hands a
// framebuffer to the server by name is preceded by a Named record.
//
// WHAT Target CHANGES, FIELD BY FIELD. NO FIELD IN THIS RECORD REFERS TO "the currently
// bound framebuffer" - every one of them describes the object named by Fbo - and that is
// the invariant a reader depends on:
//
// Fbo, Color[], Depth, Stencil, ReadSurface, Width/Height/Layers/Samples,
// FixedSampleLocations, IsDefault, Complete
// properties of the object named by Fbo, identical in meaning under every Target.
// In particular ReadSurface is resolved from THAT framebuffer's own read buffer -
// on a Named record too - never from whichever framebuffer is bound to GL_READ.
// DrawBuffers[]
// a property of the named object; it reaches the driver's bound draw framebuffer
// only when Target is Draw or Both. Under Named it is stored with the object and
// applied when that object is next configured.
// Target
// the only binding-specific field: Draw/Read/Both name the binding(s) this record
// also sets, Named names none. It is a ContentHash input.
// ContentHash
// per RECORD, not per object, and the emitter's suppressor is keyed by the
// framebuffer named: a Named record must never be suppressed against the same
// object's bound record, nor one object's Named record against another's.
struct MGPFramebufferState {
MGPipeHandle Fbo; // kMGPipeDefaultFramebuffer for the default framebuffer
MGPSurface Color[8];
MGPSurface Depth, Stencil;
// The RESOLVED read surface, not an index. This is what structurally closes the
// The RESOLVED read surface, not an index, and it is THIS framebuffer's own read
// buffer under every Target - Named included. This is what structurally closes the
// read-buffer-shared-FBO defect class.
MGPSurface ReadSurface;
Int8 DrawBuffers[8]; // attachment index, -1 = NONE
// attachment index, -1 = NONE. The named object's array; applied to the bound draw
// framebuffer only when Target is Draw or Both.
Int8 DrawBuffers[8];
Uint16 Width, Height, Layers, Samples;
Uint8 FixedSampleLocations, IsDefault, Complete, Pad0;
// Complete is FramebufferObject::CheckCompleteness(), the frontend-only answer - NOT
// glCheckFramebufferStatus's. CheckFramebufferStatus_State additionally consults
// ActiveBackendRejectsDistinctDepthStencil() and HasNonRenderableColorAttachment,
// which read the backend's probed format-capability cache; a client emitting that
// answer would be reading the backend from the client side, which is the exact
// coupling this boundary exists to remove. A later phase must not assume the stronger
// answer, and glCheckFramebufferStatus keeps answering from the frontend as it does
// today.
Uint8 FixedSampleLocations, IsDefault, Complete;
// MGPipeFramebufferTarget, above (P4a, D-C2; was Pad0). Draw/Read/Both also set the
// named binding(s); Named sets none (ID-19). The ONLY binding-specific field.
Uint8 Target;
Uint32 Pad1;
// Two jobs (section 4.5.6): the server's render-pass memo key, and the CLIENT's
// emission suppressor - an unchanged hash means this record is not sent at all.
// The same pattern is mandatory for every kVarTail set_* below, or 26.2's
// redundant glBindSampler traffic reappears as a variable-length record per batch.
// Target is one of its inputs, and the suppressor is keyed per framebuffer.
Uint64 ContentHash;
};
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;
// P4a, D-G2. MobileGL's texture-unit space is ONE MERGED array of
// TextureState::MAX_TEXTURE_IMAGE_UNITS = 192 - there is no stage dimension on
// set_sampler_views / bind_sampler_states / set_shader_images, because the same unit may
// be sampled from two stages and per-stage 32 is an advertised number rather than a
// storage shape. These two bound the three var-tail sets' Start + Count, and a record
// that names a window outside them is Fatal{ProtocolCorruption} - the var-tail window IS
// the bound and entries outside it are not cleared.
//
// Pinned against the frontend constant in MG_Impl/Pipe/PipeFill.cpp, the one translation
// unit that sees both, exactly as kMGPipeMaxVertexAttribs is: this header may not include
// a frontend one.
inline constexpr Uint32 kMGPipeMaxTextureUnits = 192;
inline constexpr Uint32 kMGPipeMaxImageUnits = 192;
struct MGPVertexBuffer {
MGPipeHandle Res;
Uint64 Offset;
@@ -361,9 +728,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 +897,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 +916,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");
@@ -566,16 +954,50 @@ namespace MobileGL::MG_Pipe {
// decision belongs on the side that pays the GPU cost. Mali prices texture upload by
// JOB COUNT: ~100 sprite rects against one union box measured +6 ms/frame.
//
// `Target` IS TWO FACTS IN ONE Uint16 (P4a, D-D3 / ID-12), and MGPipePackSubDataTarget
// under the struct is the only spelling of the encoding - nothing may open-code a half:
//
// low byte = MGPipeResourceTarget - WHICH KIND of storage the destination is.
// The applier branches on it: a buffer
// target dispatches into MGPipeResourceOps,
// every other target accumulates a pending
// upload for the texture sync to consume.
// high byte = MobileGL::TextureUploadTarget - WHICH cube face / upload target the level
// belongs to. It is NOT derivable from the
// resource target - six faces share TexCube
// - and 26 enumerators leave a byte ample.
//
// WHY THAT WAY ROUND, AND WHY THE ENCODING LIVES HERE RATHER THAN IN EACH EMITTER. The
// applier's SubDataNamesABuffer tests the WHOLE field == 0, and
// TextureUploadTarget::Texture1D is 0 - so a texture record carrying the bare upload
// enumerator is indistinguishable from a buffer record exactly when its owner is a 1D
// texture, and that texture's upload is dispatched into the buffer path. With the
// resource target in the LOW byte a buffer record's Target stays EXACTLY
// kMGPipeResourceTargetBuffer - P3a's buffer records are unchanged on the wire, their
// upload byte being zero too - while a texture record can never be zero, because no
// texture's MGPipeResourceTarget is. The static_assert under the struct holds that
// invariant, and the applier's whole-field test stays right either way.
//
// 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
// the only spelling of this convention; nothing else reads the box for a buffer.
struct MGPSubData {
MGPipeHandle Res;
// Target is PACKED - see the block above, and read it only through
// MGPipeSubDataResourceTargetOf / MGPipeSubDataUploadTargetOf below.
Uint16 Target, Level;
// Replaces the backend's `uploadData == mipData` pointer comparison: are these
// bytes an untransformed level shadow?
@@ -588,6 +1010,38 @@ namespace MobileGL::MG_Pipe {
};
MGP_ASSERT_POD(MGPSubData, 72);
// The one spelling of MGPSubData::Target's encoding, stated above the struct.
//
// Uint32 ARGUMENTS RATHER THAN THE TWO ENUM TYPES, and that is deliberate. This header is
// the contract: MGPipeResourceTarget is minted in it, but the upload half is MG_State's
// TextureUploadTarget, and nobody who reads the packed field ever needs that type - the
// applier and both backends read the halves BACK, as bytes, through the two accessors.
// Naming it in a signature would pin the contract's own API to a frontend enum for no
// reader's benefit, and would stop kMGPipeResourceTargetBuffer being passed as it stands.
// Callers pass static_cast<Uint32>(MobileGL::TextureUploadTarget) for `uploadTarget` and
// static_cast<Uint32>(MGPipeResourceTarget) - or kMGPipeResourceTargetBuffer - for
// `resourceTarget`.
constexpr inline Uint16 MGPipePackSubDataTarget(Uint32 resourceTarget, Uint32 uploadTarget) {
return static_cast<Uint16>((resourceTarget & 0xFFu) | ((uploadTarget & 0xFFu) << 8));
}
// Comparable against static_cast<Uint8>(MGPipeResourceTarget) / kMGPipeResourceTargetBuffer.
constexpr inline Uint8 MGPipeSubDataResourceTargetOf(Uint16 packed) {
return static_cast<Uint8>(packed & 0xFFu);
}
// Comparable against static_cast<Uint8>(MobileGL::TextureUploadTarget).
constexpr inline Uint8 MGPipeSubDataUploadTargetOf(Uint16 packed) {
return static_cast<Uint8>((packed >> 8) & 0xFFu);
}
// THE INVARIANT P3a's records and the applier's buffer test both rest on: a buffer
// record's Target is exactly kMGPipeResourceTargetBuffer, whole field, upload byte and
// all. TextureUploadTarget::Texture1D is 0, so the buffer case is the one place where the
// packed form and a bare enumerator agree - and it has to stay that place.
static_assert(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer, 0u) ==
kMGPipeResourceTargetBuffer,
"a buffer sub-data record's Target must stay exactly "
"kMGPipeResourceTargetBuffer: the applier's SubDataNamesABuffer tests the "
"whole field == 0");
// Encodes a buffer byte range into the record's box. False, with the record untouched,
// when the range does not fit one record: the emitter has to split it.
inline Bool MGPipeSetSubDataBufferRange(MGPSubData& record, Uint64 offset, Uint64 size) {
@@ -606,6 +1060,76 @@ 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.
//
// P4a, D-A2: THE PREDICATE IS NARROWED TO NAME THE BUFFER TARGET, and that is a
// requirement rather than a tidy-up. glTexStorage* also sets Immutable - it is a real
// descriptor fact the backend reads, and the client must set it - but texture allocation
// is already deferred to sync time in monolith (glTexImage*/glTexStorage* only
// MarkStorageDirty; even glRenderbufferStorage* allocates lazily inside SyncToBackend), so
// splitting changes no observable behaviour and this batch must NOT ack. glBufferStorage
// stays the only entry point allowed a synchronous acknowledgement.
//
// PipeCatalogueTest.ResourceRespecifyAcksOnlyImmutableStorage drives glTexStorage2D and
// glRenderbufferStorage idioms through it, and is the negative control for a future
// widening.
inline Bool MGPipeResourceRespecifyNeedsAck(const MGPResourceDesc& desc) {
return desc.Immutable != 0 && desc.Target == kMGPipeResourceTargetBuffer;
}
// P4a, ID-18 M4: A RESPECIFY WHOSE STORAGE-DEFINING FIELDS ALL EQUAL THE STORED
// DESCRIPTOR IS A METADATA UPDATE, NOT A REALLOCATION.
//
// MGPResourceDesc::BindMask and ImageBindableHint are STICKY facts the client discovers
// AFTER allocation - a texture first bound as a shader image, first used as a render
// target - and they ride resource_create and every resource_respecify. An IMMUTABLE
// texture never has a later respecify, so without a rule those two would reach the server
// only by accident, or never; with one, a mask change after allocation emits a
// resource_respecify that REPEATS the storage the resource already has.
//
// The applier and both twins must classify such a record as a metadata update:
// - NO reallocation acknowledgement. MGPipeResourceRespecifyNeedsAck above still
// answers the per-record question, but a metadata update allocates nothing, so a
// record it classifies as metadata is not acked even when that predicate says the
// call may require one.
// - NO PendingUploads clear when the call names NO level. This REFINES the whole-resource
// clear: identical storage fields with a null MGPRespecifiedLevel clear NOTHING. (The
// level-scoped rule exists because clearing the whole vector on a level-1 definition
// silently dropped level 0's accepted texels; a metadata update must drop neither.) A
// call that NAMES a level is that level's redefinition whatever the descriptor says -
// a non-base level's extent is not a descriptor field - and drops exactly that level
// (P4a final review C-1); the client's mask republish passes null on purpose.
// - The stored descriptor's BindMask and ImageBindableHint ARE updated: the applier
// replaces the descriptor WHOLE with the one the client sent (PipeApply.cpp), and the
// mask in it is the CLIENT's sticky OR (TextureEmit.h's entry, never cleared), so the
// replacement can never lose a bit the record once carried. The twin re-derives its
// storage flags from the new mask on its next sync, recreating backend storage only
// where the backend actually needs it. The record itself is not a request to recreate.
//
// THE STORAGE-DEFINING FIELD SET, named here so that neither side has to guess and a
// later field cannot join it by silence. It is every MGPResourceDesc member except the
// three metadata ones and the padding:
//
// Target, StorageKind, InternalFormat, Width, Height, Depth, ArrayLayers, Levels,
// Samples, FixedSampleLocations, Immutable, Usage, StorageFlags, HasDefinedContent,
// ViewOf, BufferForTexBuffer, BufOffset, BufSize.
//
// `Resource` is the identity the stored descriptor is looked up BY, not a comparand. The
// three fields that may differ on a metadata update are exactly BindMask,
// ImageBindableHint and GlNameForDiag (diagnostics only, never an identity, never a memo
// key). HasDefinedContent is storage-defining ON PURPOSE: glBufferData(size, NULL) at an
// unchanged size is an orphaning reallocation and has to keep clearing, rather than being
// mistaken for a mask change. A field added to MGPResourceDesc must be placed in one of
// the two lists in the same commit - PipeCatalogue pins the struct's size for that.
// 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
File diff suppressed because it is too large Load Diff
+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) \
+52 -9
View File
@@ -78,26 +78,36 @@
F(Cso) F(Texture) F(InternalFormat) F(Target) F(MinLevel) F(NumLevels) F(MinLayer) F(NumLayers) \
F(Samples) F(FixedSampleLocations)
// P4a, D-E1: BuiltinSampler and SamplerResync. Pad0 stays unlisted - gen_pipe.py's
// PADDING_MEMBER_RE (^Pad\d*$) excludes it, and a member that stops being called Pad<n> MUST
// gain a row here or pipe-gates goes red.
#define MGP_FIELDS_MGPTextureParams(F) \
F(Res) F(BaseLevel) F(MaxLevel) F(Swizzle) F(DepthStencilMode) F(ForceResync) F(MinLod) F(MaxLod) \
F(LodBias)
F(Res) F(BuiltinSampler) F(BaseLevel) F(MaxLevel) F(Swizzle) F(DepthStencilMode) F(ForceResync) \
F(SamplerResync) F(MinLod) F(MaxLod) F(LodBias)
#define MGP_FIELDS_MGPProgramDesc(F) \
F(Cso) F(StageMask) F(GlobalUboSize) F(ReservedNumSamplesOffset) F(SpirvStatus) F(NativeFloat64) \
F(PointSizeDemoted) F(EnableSpirvValidation) F(Spirv) F(Reflection)
// P4a, ID-12 / esprytobj DV-5: Pad0 became Uint16 TextureTarget. Same trip wire as
// MGPFramebufferState's Target below - PADDING_MEMBER_RE only excludes a member still NAMED
// Pad<n>, so the rename without this row is a pipe-gates failure, and the row without the
// rename is one too. A meaning-carrying byte cannot enter this record silently.
#define MGP_FIELDS_MGPSurface(F) \
F(Res) F(InternalFormat) F(Kind) F(Layered) F(Level) F(Layer) F(UploadTarget)
F(Res) F(InternalFormat) F(Kind) F(Layered) F(Level) F(Layer) F(UploadTarget) F(TextureTarget)
// P4a, D-C2: Pad0 became Uint8 Target, and gen_pipe.py's PADDING_MEMBER_RE only excludes a
// member still NAMED Pad<n> - so the rename without this row is a pipe-gates failure, which
// is exactly the trip wire that makes the byte impossible to add silently.
#define MGP_FIELDS_MGPFramebufferState(F) \
F(Fbo) F(Color) F(Depth) F(Stencil) F(ReadSurface) F(DrawBuffers) F(Width) F(Height) F(Layers) \
F(Samples) F(FixedSampleLocations) F(IsDefault) F(Complete) F(ContentHash)
F(Samples) F(FixedSampleLocations) F(IsDefault) F(Complete) F(Target) F(ContentHash)
#define MGP_FIELDS_MGPVertexBuffer(F) \
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 +154,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 +244,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) \
@@ -250,6 +264,18 @@
F(SwapBytes) F(LSBFirst) F(RowLength) F(ImageHeight) F(SkipPixels) F(SkipRows) F(SkipImages) \
F(Alignment)
// P4a, D-F1: THE PADDING TRAP. SamplerParameters is sizeof == 100 with THREE BYTES OF
// TRAILING PADDING (96 bytes of members plus the 1-byte borderColorForm) and had no field
// table and no MGP_VERIFY_PAYLOAD_LIST row at all, so MGPSamplerDesc's blob was compared as
// BYTES and MOBILEGL_PIPE_VERIFY could false-differ on uninitialised padding - a coin flip
// rather than a gate. With this list the comparator sees the sixteen members and the three
// bytes can never enter the answer. The client-side CSO cache hashes and memcmp-confirms over
// a ZERO-INITIALISED canonical copy for the same reason, which is the other half of D-F1.
#define MGP_FIELDS_SamplerParameters(F) \
F(wrapS) F(wrapT) F(wrapR) F(minFilter) F(magFilter) F(mipmapMode) F(minLod) F(maxLod) \
F(lodBias) F(maxAnisotropy) F(compareFunc) F(compareMode) F(borderColor) F(borderColorI) \
F(borderColorUI) F(borderColorForm)
#define MGP_FIELDS_PerBufferBlendState(F) \
F(Enabled) F(SrcFactorRGB) F(DstFactorRGB) F(SrcFactorAlpha) F(DstFactorAlpha) F(ColorEquation) \
F(AlphaEquation)
@@ -291,6 +317,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) \
@@ -307,7 +348,9 @@
P(MGPDrawRange) P(MGPDrawIndirect) P(MGPGridInfo) P(MGPMemoryBarrier) P(MGPStreamOutputBegin) \
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(RenderStateParameters) P(PixelStoreParameters) P(SamplerParameters) P(PerBufferBlendState) \
P(StencilFaceState) \
P(DynamicBackendParameters) P(MGHostSpan) \
P(MGPVertexAttribWire) P(MGPVertexBindingPointWire)
// clang-format on
+385
View File
@@ -33,10 +33,395 @@ 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 - and, since
// P4a (fable seam F-3), any STORAGE DEFINITION of a texture or a renderbuffer, because
// set_framebuffer_state inlines an attachment's format, extent and samples and those
// setters are the only writers of what it inlines (TextureObject.cpp /
// RenderbufferObject.cpp, PipePublishDescriptor).
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;
// P4a's five, for the BIRTH half at the tail of this header. Declarations only, exactly as
// BufferObject is: none of the hooks below needs a definition, and this header must not
// gain one - reaching a frontend class header from here would put the state machine's own
// types in front of every mutator that spells MGP_NOTE_MUTATION.
class ITextureObject;
class RenderbufferObject;
class FramebufferObject;
class SamplerObject;
class ProgramObject;
}
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);
// ---- P4a: ONE CLIENT-SIDE DEATH HELPER PER KIND P4a MINTS (brief D-I1) ----
//
// BACKEND-NEUTRAL FROM DAY ONE, and this is the P3a final-review lesson taken forward
// rather than repeated. Before it, the only thing that ever returned a VertexElementsCso
// slot was DirectGLES' StateObjectDeathOps table; under a 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 applier record for the life of the process, and
// past 65536 slots every create became a permanent Fatal{ProtocolCorruption}. P4a mints
// SIX kinds, so the rule is stated once and obeyed six times: whatever mints a handle owns
// the death of that handle, the client mints all six, and a backend death notice is a
// redundant SECOND path that must be idempotent - which it is, because it resolves through
// the same lifetimeId -> slot map these free, and MGPipeSlotAllocator::Free refuses a slot
// that is not live at that generation.
//
// THE ORDER INSIDE EACH IS FIXED AND IS NOT A PACKAGE'S CHOICE:
// 1. emit the wire delete FIRST - it drops the applier's record while the record still
// exists, so a recycled slot cannot inherit a field;
// 2. raise NotifyStateObjectDestroyed SECOND - it resolves the handle through the
// allocator, and a backend told after the Free could no longer find its twin, which
// moves the leak from the client to the driver object;
// 3. free the slot LAST, and a double free on a stale generation is a proven no-op
// because Free bumps no generation (the bump rides the next handout).
//
// ALL SIX TAKE THE LIFETIME ID rather than the object, for MGPipeEmitVertexElementsDestroy
// AndFree's reason: they run from a destructor, where the last SharedPtr has already
// dropped, and the lifetime id is what the slot allocator resolves the handle from. It is
// also what keeps this header a declaration-only coupling - no frontend class needs
// forward-declaring for any of them.
//
// Each returns whether its wire delete actually went out, which is the LATCH taken at the
// object's create and not a second reading of the subsystem predicate: an object born
// while a subsystem bit was clear and destroyed after it was set would otherwise free its
// slot with the applier's record still Live, on a slot about to be handed out again. The
// legacy path runs only when the answer is false.
// ResourceDestroy, and then the SamplerViewCso minted off this same lifetime id (P4a
// D-F2: one sampler view per ITextureObject). Called from TextureObjectBase's VIRTUAL
// destructor, so 2D / 3D / cube / buffer / view all announce exactly once.
Bool MGPipeEmitTextureDestroyAndFree(Uint64 lifetimeId);
// ResourceDestroy.
Bool MGPipeEmitRenderbufferDestroyAndFree(Uint64 lifetimeId);
// NO WIRE CALL AT ALL (D-I2). PipeCalls.def has no framebuffer delete, because a
// framebuffer is not a resource and is not a CSO - it is STATE, and set_framebuffer_state
// is the only call that names one - and the catalogue is closed, so P4a does not invent a
// row. The handle is minted and freed entirely client-side and this helper does steps 2
// and 3 only. A recycled framebuffer handle is distinguished by Gen, which is inside the
// record's ContentHash, so it can never be suppressed against its predecessor's record.
Bool MGPipeEmitFramebufferDestroyAndFree(Uint64 lifetimeId);
// DeleteSamplerState. Also the path the content-addressed CSO cache's LRU eviction takes,
// which is why it is addressed by lifetime id and not by "the object that owns it".
Bool MGPipeEmitSamplerCsoDestroyAndFree(Uint64 lifetimeId);
// DeleteSamplerView. Called by the texture helper above; a sampler view has no frontend
// object of its own, so this is the only path there is.
Bool MGPipeEmitSamplerViewCsoDestroyAndFree(Uint64 lifetimeId);
// DeleteShaderState, for an ordinary program AND for a program-pipeline COMPOSITE, whose
// slot has two independent release paths - the pipeline cache's LRU eviction and the
// composite ProgramObject's own destructor. One helper for both, and the second call is a
// proven no-op.
Bool MGPipeEmitShaderCsoDestroyAndFree(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);
// ================================================================================
// P4a: THE BIRTH HALF, one hook per client path MG_State owns (D-C .. D-I)
// ================================================================================
//
// The death helpers above are half a lifetime. The other half is emitted from MG_State
// too - a texture's create from its constructor, a renderbuffer's respecify from its
// storage mutators, a texture's params from glTexParameter*, a sampler CSO from the
// sampler object, a shader CSO from the program - because that is where the event
// happens, exactly as P3a's buffer family emits from BufferObject's own dispatchers
// (ARCHITECTURE.md 5.1 names those as the ONE exception to push-at-validate). Only the
// texture sub-data DRAIN runs at the validate point, and even it is fed from here: the
// drain list is appended on a level's first dirty mark.
//
// WHY THEY ARE DECLARED HERE. This header is the one door MG_State has into the client
// (check_include_closure.py's mutation-header probe pins it: reaching
// MG_Impl/Pipe/*Emit.h from a frontend mutator would pull the client's emitters into the
// state machine that calls them). So a hook a frontend mutator calls is DECLARED here and
// DEFINED in MG_Impl/Pipe/PipeFill.cpp, which is package A's for the whole phase - the
// same "declaration here, definition there" split MGPipeMintResourceHandle and
// MGPipeEmitResourceCreate use, and the reason no file is touched twice.
//
// WHAT EACH BODY DOES, and the division is fixed:
// * PipeFill.cpp owns the GATE - the subsystem bit in MOBILEGL_PIPE_PUSH *and* the
// family's own kMGPipeWired*Subsystem constant, the same pair the validate point's
// `wants()` applies to every emission - and the four MINTS, which are pure allocator
// work and need no family knowledge;
// * the FAMILY EMITTER (MG_Impl/Pipe/<Family>Emit.h, owned by package B or C) owns the
// payload build, the handle rule for its own kind and the PUBLICATION LATCH below.
// PipeFill.cpp forwards to it through an entry point that is compiled only while that
// family's wired constant is non-zero, so this tree links against the STUB emitters
// and against the finished ones with no edit to PipeFill.cpp - and a family that sets
// its constant without providing the entry point is a COMPILE ERROR in its own commit
// rather than a surprise at the merge. The entry point each hook forwards to is named
// beside it and spelled out in PipeFill.cpp's contract block.
//
// NOTHING CALLS ANY OF THEM AT THE CONTRACT COMMIT. B and C add the call sites in the
// five MG_State directories C.7 gives them, in the SAME commit that gives the emitter its
// body - by EDITING an existing constructor/mutator body, never by adding one (G1).
// ---- the publication latch (D-I1), and it is the ONE answer both halves read ----
//
// The create is gated at its call site and the destroy inside the death helper, so the
// two ask the same question at two different moments. An object born while its subsystem
// bit was clear and destroyed after it was set would otherwise free its slot with the
// applier's record still Live - on a slot the allocator is about to hand out again. A
// slot is NOT evidence of a record either: a backend twin table mints one through
// MGPipeSlots().Acquire whether or not the subsystem ever asked this client to emit a
// create, and a delete_* on such a handle is a refused call the applier asserts on.
//
// So the emitter latches the answer when its create actually goes out, the death helper
// reads the latch, and the latch is keyed by {kind, slot, gen} so a recycled slot cannot
// inherit its predecessor's answer. Defined in PipeFill.cpp beside the six death helpers,
// declared here because both the helpers and the five emit headers read it.
void MGPipeNoteHandlePublished(MGPipeKind kind, MGPipeHandle handle);
Bool MGPipeHandleIsPublished(MGPipeKind kind, MGPipeHandle handle);
void MGPipeNoteHandleUnpublished(MGPipeKind kind, MGPipeHandle handle);
// ---- the four mints (pure allocator work, no family knowledge) ----
//
// UNCONDITIONAL in a push build, for MGPipeMintResourceHandle's reason: a handle is CLIENT
// state and other subsystems name these objects by handle whether or not their own family
// is switched on - MGPSurface::Res names a Texture or a Renderbuffer out of the framebuffer
// subsystem, MGPBoundView::Texture and MGPImageView::Res name a Texture out of the sampler
// one. Gating the mint on the family bit would make those emit null handles in exactly the
// A/B arm that exists to isolate the families. Each costs one free-list pop and one map
// insert per object and emits nothing.
void MGPipeMintTextureHandle(MG_State::GLState::ITextureObject& texture);
void MGPipeMintRenderbufferHandle(MG_State::GLState::RenderbufferObject& renderbuffer);
// A framebuffer has a handle and NO wire lifetime (D-I2): set_framebuffer_state is the only
// call that names one, and there is no create or destroy for the kind. The mint is still
// the object's, so the identity exists before the first validate point that pushes it.
void MGPipeMintFramebufferHandle(MG_State::GLState::FramebufferObject& framebuffer);
// Ordinary programs only. A program-pipeline COMPOSITE is minted by the composite resolver
// out of the reserved band through MGPipeSlotAllocator::AllocateComposite, which is the one
// door into it, and it is not a frontend construction event.
void MGPipeMintShaderCsoHandle(MG_State::GLState::ProgramObject& program);
// ---- textures and renderbuffers: MG_Impl/Pipe/TextureEmit.h, package B ----
//
// resource_create from ITextureObject's constructor and RenderbufferObject's;
// resource_respecify from every storage-defining entry point, including
// RenderbufferObject::{SetInternalFormat, AllocateStorage, SetSamples}, which publish
// nothing at all today (D-D2); set_texture_params from the parameter mutators, which is
// where the READ-attachment-only gap D-E3 closes.
//
// Entry points MGPipeTextureEmitter must provide, all taking the frontend object by
// reference and returning void:
// EmitResourceCreate(ITextureObject&)
// EmitResourceRespecify(ITextureObject&, MGPipeTextureRespecifyScope, Uint32 uploadTarget,
// Uint32 level)
// EmitTextureParams(ITextureObject&)
// NoteLevelDirty(ITextureObject& storageOwner, Uint32 uploadTarget, Uint32 level)
// EmitRenderbufferCreate(RenderbufferObject&) / EmitRenderbufferRespecify(RenderbufferObject&)
void MGPipeEmitTextureResourceCreate(MG_State::GLState::ITextureObject& texture);
// WHICH STORAGE A TEXTURE RESPECIFY REPLACES (P4a final review C-1). The applier scopes
// its pending-upload clear on this answer and not on the descriptor, because the
// descriptor cannot give it: AllocateStorage is per (uploadTarget, level) and
// TruncateMipmapLevels removes every level at or above a cut, while MGPResourceDesc
// carries only the base extent and the level count. A level the applier had ACCEPTED at
// one verb (the client's dirty flag already clear, D-D5 step 1) and that a later per-level
// definition redefined AROUND was dropped by the whole-resource arm with nobody owing its
// texels - so every respecify states its scope, and "whole resource" is said, never
// defaulted. The emitter builds wire's MGPRespecifiedLevel from the pair, packed exactly
// as the drain packs a sub-data record's Target (MGPipePackSubDataTarget), so the key it
// drops is the key that level's emission made.
enum class MGPipeTextureRespecifyScope : Uint32 {
// The whole store is redefined or restated: a format, sample-count or
// fixed-sample-locations change, an immutable allocation completing
// (SetImmutableLevels), a texture view's creation. Every pending upload goes.
WholeResource = 0,
// ONE (uploadTarget, level) was (re)allocated: glTexImage*D, glCompressedTexImage*D,
// glCopyTexImage*D, one level of a glTexStorage* loop, one level of a generated-mipmap
// grow. That level's pending upload goes; every other level's stays. `uploadTarget` and
// `level` name it.
OneLevel = 1,
// The chain was cut: every level of `uploadTarget` at or above `level` is gone and the
// levels below it are untouched (glGenerateMipmap fitting the chain, a base-level
// redefinition discarding its tail, glTexStorage* fitting the chain to its level
// count). `level` is the first level removed; a cut at 0 is the whole resource.
LevelsFrom = 2,
};
void MGPipeEmitTextureResourceRespecify(MG_State::GLState::ITextureObject& texture,
MGPipeTextureRespecifyScope scope, Uint32 uploadTarget,
Uint32 level);
void MGPipeEmitTextureParams(MG_State::GLState::ITextureObject& texture);
// The DRAIN LIST's append, on a level's FIRST dirty mark, keyed on the STORAGE OWNER from
// day one (D-D4: a view and its owner already share one dirty state, so an upload through
// either lands on the same key). The record itself is emitted at the validate point by
// MGPipeTextureEmitter::DrainTextureSubData; this is only what puts the level on the list,
// and walking every live texture per verb is the cost it exists to avoid.
void MGPipeNoteTextureLevelDirty(MG_State::GLState::ITextureObject& storageOwner, Uint32 uploadTarget,
Uint32 level);
void MGPipeEmitRenderbufferResourceCreate(MG_State::GLState::RenderbufferObject& renderbuffer);
void MGPipeEmitRenderbufferResourceRespecify(MG_State::GLState::RenderbufferObject& renderbuffer);
// ---- D-A4's two sticky bind-mask producers (P4a final review M-A) ----
//
// kMGPipeBindSampler is "any texture the sampler-view resolution names in an emitted
// MGPBoundView" and kMGPipeBindShaderImage "any texture named in an emitted MGPImageView"
// - both the SAMPLER package's emitters (SamplerEmit.h, ImageEmit.h), which the texture
// emitter's header includes and which therefore cannot include it back - and, earliest of
// all, glBindImageTexture's state setter (TextureState.h, MG_State), which may include no
// emit header at all. So the note goes through this door, exactly as the birth hooks do.
// Nothing produced either bit before the fix round: ImageBindableHint was always 0, the
// metadata respecify (ID-18 M4) had no live trigger, and the remint pull the hint exists to
// prevent was neither prevented nor counted.
//
// UNCONDITIONAL IN A PUSH BUILD, like the mints: the mask is CLIENT state the framebuffer
// emitter ORs into whether or not the texture family is on, and the emission a mask move
// causes (the metadata respecify) is gated inside the emitter on the family's own pair.
void MGPipeNoteTextureBoundAs(MGPipeHandle texture, Uint32 bindBit);
// glBindImageTexture. The hint is the PREVENTION half of the texture-remint stall class -
// a texture the server knows may be image-bound is allocated image-bindable up front - so it
// has to reach the applier before the texture's first sync, i.e. at the bind itself, not at
// the validate point's image walk (which notes it as well, D-A4's letter).
void MGPipeNoteTextureImageBound(MG_State::GLState::ITextureObject& texture);
// ---- sampler CSOs and sampler views: MG_Impl/Pipe/SamplerEmit.h, package C ----
//
// Entry points MGPipeSamplerEmitter must provide, returning void:
// EmitSamplerCso(SamplerObject&) - D-F1's content-addressed mint-or-share at
// capacity 256, hashed field-wise over a canonical
// zero-initialised copy, behind the version-first
// skip. The HANDLE RULE FOR THIS KIND IS THE
// EMITTER'S, not this file's: two identical
// samplers share one CSO, so there is deliberately
// no per-object mint above, and it is the emitter
// that decides which lifetime id (if any) owns the
// slot the death helper will resolve.
// EmitSamplerView(ITextureObject&) - D-F2's ONE view per texture object, minted off
// the texture's own lifetime id and re-issued on
// the SAME handle when the restrictions move.
void MGPipeEmitSamplerCsoCreate(MG_State::GLState::SamplerObject& sampler);
void MGPipeEmitSamplerViewCreate(MG_State::GLState::ITextureObject& texture);
// ---- programs: MG_Impl/Pipe/ProgramEmit.h, package C ----
//
// Entry point MGPipeProgramEmitter must provide, returning void:
// EmitShaderCso(ProgramObject&)
//
// Re-issued on the SAME handle whenever the link version moves, exactly as
// create_vertex_elements is (Gen moves only on slot reuse). D-H4 keeps the TRACKER out of
// it - bit 6's shutter reads GetCurrentProgram() and deliberately not GetProgramForDraw(),
// because the tracker must not force a compile to answer "did the shader move" - so the
// ordinary emission is the validate point's, from the join the verb was going to make
// anyway. This hook exists for the paths that are NOT a draw: a link that completes off
// the draw path still owns its own publication.
void MGPipeEmitShaderCsoCreate(MG_State::GLState::ProgramObject& program);
} // 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
+101
View File
@@ -299,6 +299,107 @@ 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,
SetDispatchProgram,
SetDrawProgram,
SetDynamicState,
SetFramebufferState,
SetPatchState,
SetSamplerViews,
SetShaderImages,
SetVertexAttribDefaults,
};
inline constexpr const char* kMGPipeFieldEmitterNames[] = {
"kNone",
"BindRenderState",
"BindVertexElements",
"CreateRenderState",
"SetDispatchProgram",
"SetDrawProgram",
"SetDynamicState",
"SetFramebufferState",
"SetPatchState",
"SetSamplerViews",
"SetShaderImages",
"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::SetFramebufferState, // GetFramebufferBindingSlot
MGPipeFieldEmitter::SetShaderImages, // GetImageTextureBinding
MGPipeFieldEmitter::SetDynamicState, // GetLineWidth
MGPipeFieldEmitter::CreateRenderState, // GetLogicOp
MGPipeFieldEmitter::SetSamplerViews, // 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::SetDispatchProgram, // GetProgramForDispatch
MGPipeFieldEmitter::SetDrawProgram, // 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::SetSamplerViews, // 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 = 40;
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[];
+25 -1
View File
@@ -109,10 +109,13 @@ inline Bool MGPipeVerify(const MGPSwapInterval& a, const MGPSwapInterval& b, con
inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const char** outField);
inline Bool MGPipeVerify(const RenderStateParameters& a, const RenderStateParameters& b, const char** outField);
inline Bool MGPipeVerify(const PixelStoreParameters& a, const PixelStoreParameters& b, const char** outField);
inline Bool MGPipeVerify(const SamplerParameters& a, const SamplerParameters& b, const char** outField);
inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState& b, const char** outField);
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 {};
@@ -245,6 +248,8 @@ struct MGPipeHasFieldVerifier<RenderStateParameters> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<PixelStoreParameters> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<SamplerParameters> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<PerBufferBlendState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<StencilFaceState> : std::true_type {};
@@ -252,6 +257,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) {
@@ -623,6 +632,11 @@ inline Bool MGPipeVerify(const PixelStoreParameters& a, const PixelStoreParamete
return true;
}
inline Bool MGPipeVerify(const SamplerParameters& a, const SamplerParameters& b, const char** outField) {
MGP_FIELDS_SamplerParameters(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const PerBufferBlendState& a, const PerBufferBlendState& b, const char** outField) {
MGP_FIELDS_PerBufferBlendState(MGP_VERIFY_FIELD)
return true;
@@ -643,6 +657,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 = 72;
+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,27 @@ namespace MobileGL::MG_State::GLState {
return s_nextFramebufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
#if MOBILEGL_PIPE_PUSH
FramebufferObject::~FramebufferObject() {
// P4a D-I2: A FRAMEBUFFER HAS A HANDLE AND NO WIRE LIFETIME. PipeCalls.def carries
// resource_destroy and five delete_* rows and NO framebuffer delete, because a
// framebuffer is not a resource and is not a CSO - it is STATE, and
// set_framebuffer_state is the only call that names one - and the catalogue is closed,
// so P4a invents no row. The helper is therefore steps 2 and 3 only: the death notice,
// raised while the handle still resolves (this is the P2 step-e2 announcement that used
// to stand here alone - the last SharedPtr to this object dropping, not the glDelete*
// that only marks the name and leaves a still-bound object very much alive), and then
// the slot.
//
// What makes a dangling Fbo unreachable is the frontend's own
// MarkFramebufferObjectForDeletion path, which already rebinds any slot holding the
// victim to framebuffer 0; and a RECYCLED framebuffer handle can never be suppressed
// against its predecessor's record, because Fbo carries Gen and Gen is inside the
// record's ContentHash.
MG_Pipe::MGPipeEmitFramebufferDestroyAndFree(m_lifetimeId);
}
#endif
// FramebufferAttachmentObject
FramebufferAttachmentObject::FramebufferAttachmentObject(
const SharedPtr<MG_State::GLState::ITextureObject>& texture, TextureUploadTarget textureUploadTarget, Int level,
@@ -190,6 +213,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 +225,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 +238,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)>
@@ -408,6 +408,17 @@ namespace MobileGL::MG_State::GLState {
// Free constrained templates rather than members so the struct bodies above stay a verbatim
// move. The sizeof trip wires below are what keep these tables honest: a member added to a
// struct changes its size, trips the assertion, and the message sends the author here.
//
// THE SERIALIZER NOW EXISTS (P4a): MG_State/GLState/ProgramState/ProgramArtifactsCodec.
// {h,cpp}, beside this header rather than inside it so the check_include_closure.py
// "artifacts-header" probe stays untouched. It is two visitors over the tables below - a
// writer that appends to a Vector<Uint8> and a reader that consumes one - length-prefixed,
// little-endian, with a format-version word first and a MGL_LINKARTIFACTS_SIZE echo
// second, so a struct that gained a field and a codec that did not is a mismatch at READ
// time rather than a silent truncation. Adding a member to any struct above therefore
// means: add its VisitFields row here, update the sizeof number below, and bump
// kProgramArtifactsCodecVersion. `LinkArtifacts::program` stays the one deliberate
// omission, and the codec has no arm for it.
template <class Self, class V>
requires std::same_as<std::remove_const_t<Self>, TypeFacts>
void VisitFields(Self& a, V&& v) {
@@ -549,7 +560,7 @@ namespace MobileGL::MG_State::GLState {
// ---- trip wires ----
// TypeFacts is a POD on every ABI: 13 Bool + 3 bytes of padding + 7 x 4-byte scalars.
static_assert(std::is_trivially_copyable_v<TypeFacts> && sizeof(TypeFacts) == 44,
"TypeFacts changed: add the field to VisitFields(TypeFacts) (and its serializer when one exists), then update this number");
"TypeFacts changed: add the field to VisitFields(TypeFacts) (and ProgramArtifactsCodec.cpp's serializer), then update this number");
// The container-bearing structs have one size per standard library (std::string and
// std::set differ between libstdc++ and libc++), so their numbers are pinned PER STL:
// libstdc++ (the Linux CI toolchain) here, libc++ (the NDK) by the integrator, MSVC
@@ -565,12 +576,12 @@ namespace MobileGL::MG_State::GLState {
#endif
#ifdef MGL_LINKARTIFACTS_SIZE
static_assert(sizeof(ResourceReflection) == MGL_RESOURCEREFLECTION_SIZE,
"ResourceReflection changed size: add the field to VisitFields(ResourceReflection) (and its serializer when one exists), then update this number");
"ResourceReflection changed size: add the field to VisitFields(ResourceReflection) (and ProgramArtifactsCodec.cpp's serializer), then update this number");
static_assert(sizeof(XfbVarying) == MGL_XFBVARYING_SIZE,
"XfbVarying changed size: add the field to VisitFields(XfbVarying) (and its serializer when one exists), then update this number");
"XfbVarying changed size: add the field to VisitFields(XfbVarying) (and ProgramArtifactsCodec.cpp's serializer), then update this number");
static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE,
"LinkArtifacts changed size: add the field to VisitFields(LinkArtifacts) (and its serializer when one exists), then update this number");
"LinkArtifacts changed size: add the field to VisitFields(LinkArtifacts) (and ProgramArtifactsCodec.cpp's serializer), then update this number");
static_assert(sizeof(SpirvArtifacts) == MGL_SPIRVARTIFACTS_SIZE,
"SpirvArtifacts changed size: add the field to VisitFields(SpirvArtifacts) (and its serializer when one exists), then update this number");
"SpirvArtifacts changed size: add the field to VisitFields(SpirvArtifacts) (and ProgramArtifactsCodec.cpp's serializer), then update this number");
#endif
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,303 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.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
// ProgramArtifactsCodec.h. Compiled only under MOBILEGL_PIPE_PUSH (the root CMakeLists.txt
// appends it inside `if (MOBILEGL_PIPE_PUSH)`), so the pull build gains no symbol from it.
#include "ProgramArtifactsCodec.h"
#include <bit>
#include <cstring>
#include <set>
#include <string>
#include <type_traits>
#include <vector>
namespace MobileGL::MG_State::GLState {
namespace {
// Little-endian, stated rather than assumed. Every ABI MobileGL ships on is
// little-endian; the day one is not, this is a compile error and not a silently
// byte-swapped reflection table.
static_assert(std::endian::native == std::endian::little,
"the program-archive codec writes scalars in native order and MobileGL's "
"ABIs are little-endian; a big-endian target needs explicit byte order");
// ---- the four container shapes the archive is built out of ----
//
// Detected by SHAPE rather than by naming std::vector / ska::flat_hash_map, because
// MobileGL's aliases are not all std:: types (UnorderedMap is ska::flat_hash_map) and
// a codec that named them would stop compiling the day one is swapped. The order the
// arms are tested in is what makes them unambiguous: String before every container,
// maps before sets (a map has both key_type and mapped_type), fixed arrays before
// resizable ones.
template <class T>
concept ArchiveString = std::same_as<T, String>;
template <class T>
concept ArchiveMap = requires {
typename T::key_type;
typename T::mapped_type;
};
template <class T>
concept ArchiveSet = requires { typename T::key_type; } && !ArchiveMap<T> && !ArchiveString<T>;
template <class T>
concept ArchiveFixedArray = requires { std::tuple_size<T>::value; };
template <class T>
concept ArchiveVector = !ArchiveString<T> && !ArchiveFixedArray<T> && requires(T& t) {
t.resize(SizeT{0});
t.size();
t.begin();
};
template <class T>
concept ArchiveScalar = std::is_arithmetic_v<T> || std::is_enum_v<T>;
// The ONE hand-written arm, and it is hand-written because ProgramArtifacts.h gives it
// no VisitFields table: glslang::TIntermediate::TUniformInitializer is a plain
// aggregate that merely LOOKS like a glslang type (std::string + scalars + two
// std::vectors), which is exactly what ProgramTranslationCache.h audited it as when it
// decided the archive holds no glslang-owned memory. If a field is added there, this
// arm and the format version below both have to move.
using UniformInitializer = glslang::TIntermediate::TUniformInitializer;
template <class T>
concept ArchiveUniformInitializer = std::same_as<T, UniformInitializer>;
// ---- the writer ----
template <class T>
void PutRaw(Vector<Uint8>& out, const T& value) {
static_assert(std::is_trivially_copyable_v<T>);
const SizeT at = out.size();
out.resize(at + sizeof(T));
std::memcpy(out.data() + at, &value, sizeof(T));
}
void PutCount(Vector<Uint8>& out, SizeT count) {
PutRaw(out, static_cast<Uint64>(count));
}
template <class T>
void WriteValue(Vector<Uint8>& out, const T& value);
template <class T>
void WriteSequence(Vector<Uint8>& out, const T& value) {
PutCount(out, value.size());
for (const auto& element : value) WriteValue(out, element);
}
template <class T>
void WriteValue(Vector<Uint8>& out, const T& value) {
if constexpr (ArchiveScalar<T>) {
PutRaw(out, value);
} else if constexpr (ArchiveString<T>) {
PutCount(out, value.size());
const SizeT at = out.size();
out.resize(at + value.size());
if (!value.empty()) std::memcpy(out.data() + at, value.data(), value.size());
} else if constexpr (ArchiveMap<T>) {
PutCount(out, value.size());
for (const auto& entry : value) {
WriteValue(out, entry.first);
WriteValue(out, entry.second);
}
} else if constexpr (ArchiveSet<T>) {
WriteSequence(out, value);
} else if constexpr (ArchiveFixedArray<T>) {
// No count: the width is part of the type, and writing one would let a reader
// believe a stream that disagrees with the struct.
for (const auto& element : value) WriteValue(out, element);
} else if constexpr (ArchiveVector<T>) {
WriteSequence(out, value);
} else if constexpr (ArchiveUniformInitializer<T>) {
WriteValue(out, value.name);
WriteValue(out, value.basicType);
WriteValue(out, value.vectorSize);
WriteValue(out, value.matrixCols);
WriteValue(out, value.matrixRows);
WriteValue(out, value.arraySize);
WriteValue(out, value.intValues);
WriteValue(out, value.floatValues);
} else {
// The archive's own structs: TypeFacts, ResourceReflection, XfbVarying. ONE
// table serves both directions, so a member added to any of them is carried by
// both halves of this codec the moment its VisitFields row is added - and a
// type with no table at all is a compile error here rather than a silently
// skipped field.
VisitFields(value, [&out](const char*, const auto& field) { WriteValue(out, field); });
}
}
// ---- the reader ----
struct ReadCursor {
const Uint8* Bytes = nullptr;
SizeT Size = 0;
SizeT Pos = 0;
Bool Ok = true;
SizeT Remaining() const { return Size - Pos; }
};
template <class T>
Bool TakeRaw(ReadCursor& in, T& value) {
static_assert(std::is_trivially_copyable_v<T>);
if (!in.Ok || in.Remaining() < sizeof(T)) {
in.Ok = false;
return false;
}
std::memcpy(&value, in.Bytes + in.Pos, sizeof(T));
in.Pos += sizeof(T);
return true;
}
// A COUNT IS CHECKED AGAINST THE BYTES THAT REMAIN BEFORE ANYTHING IS ALLOCATED. Every
// element this codec writes costs at least one byte, so a count larger than the
// remaining bytes cannot describe this stream - and refusing it here is what stops a
// corrupt or truncated archive from turning into a multi-gigabyte resize before the
// element loop notices it has run out.
Bool TakeCount(ReadCursor& in, SizeT& count) {
Uint64 raw = 0;
if (!TakeRaw(in, raw)) return false;
if (raw > static_cast<Uint64>(in.Remaining())) {
in.Ok = false;
return false;
}
count = static_cast<SizeT>(raw);
return true;
}
template <class T>
void ReadValue(ReadCursor& in, T& value);
template <class T>
void ReadValue(ReadCursor& in, T& value) {
if constexpr (ArchiveScalar<T>) {
TakeRaw(in, value);
} else if constexpr (ArchiveString<T>) {
SizeT count = 0;
if (!TakeCount(in, count)) return;
value.assign(reinterpret_cast<const char*>(in.Bytes + in.Pos), count);
in.Pos += count;
} else if constexpr (ArchiveMap<T>) {
SizeT count = 0;
if (!TakeCount(in, count)) return;
value.clear();
for (SizeT i = 0; i < count && in.Ok; ++i) {
typename T::key_type key{};
typename T::mapped_type mapped{};
ReadValue(in, key);
ReadValue(in, mapped);
if (!in.Ok) return;
value.emplace(Move(key), Move(mapped));
}
} else if constexpr (ArchiveSet<T>) {
SizeT count = 0;
if (!TakeCount(in, count)) return;
value.clear();
for (SizeT i = 0; i < count && in.Ok; ++i) {
typename T::key_type key{};
ReadValue(in, key);
if (!in.Ok) return;
value.insert(Move(key));
}
} else if constexpr (ArchiveFixedArray<T>) {
for (auto& element : value) {
ReadValue(in, element);
if (!in.Ok) return;
}
} else if constexpr (ArchiveVector<T>) {
SizeT count = 0;
if (!TakeCount(in, count)) return;
value.clear();
value.resize(count);
for (auto& element : value) {
ReadValue(in, element);
if (!in.Ok) return;
}
} else if constexpr (ArchiveUniformInitializer<T>) {
ReadValue(in, value.name);
ReadValue(in, value.basicType);
ReadValue(in, value.vectorSize);
ReadValue(in, value.matrixCols);
ReadValue(in, value.matrixRows);
ReadValue(in, value.arraySize);
ReadValue(in, value.intValues);
ReadValue(in, value.floatValues);
} else {
VisitFields(value, [&in](const char*, auto& field) {
if (in.Ok) ReadValue(in, field);
});
}
}
// The struct-size echo. Under a toolchain whose sizes are not pinned yet
// (ProgramArtifacts.h's libc++ branch until the integrator fills it in) this is 0,
// which still round-trips within one build - the echo compares what THIS build wrote
// against what THIS build expects - and stops mattering the moment the pin lands.
#ifdef MGL_LINKARTIFACTS_SIZE
inline constexpr Uint64 kLinkArtifactsSizeEcho = MGL_LINKARTIFACTS_SIZE;
#else
inline constexpr Uint64 kLinkArtifactsSizeEcho = 0;
#endif
} // namespace
void EncodeProgramArtifacts(const LinkArtifacts& link, const SpirvArtifacts& spirv,
Vector<Uint8>& out) {
PutRaw(out, kProgramArtifactsCodecVersion);
PutRaw(out, kLinkArtifactsSizeEcho);
// `link` is walked through its own VisitFields table, which omits the live
// SharedPtr<glslang::TProgram>: 57 of the 58 members. There is no arm here for it and
// there must not be one - it points into a glslang arena that no archived instance
// owns, and ProgramTranslationCache asserts it is null at insert.
WriteValue(out, link);
WriteValue(out, spirv);
}
Bool DecodeProgramArtifacts(const Uint8* bytes, SizeT size, LinkArtifacts& link,
SpirvArtifacts& spirv) {
// Both outputs are left in a DEFINED state on every exit, including every failure:
// a caller that ignores the return value gets an empty archive rather than half of a
// truncated one.
link = LinkArtifacts{};
spirv = SpirvArtifacts{};
if (bytes == nullptr) return false;
ReadCursor in{bytes, size, 0, true};
Uint32 version = 0;
Uint64 sizeEcho = 0;
if (!TakeRaw(in, version) || !TakeRaw(in, sizeEcho)) return false;
// REFUSED, NOT GUESSED. A different version word or a struct that changed width means
// the bytes describe a layout this build does not have; deserialising them anyway
// writes garbage into the tail of a reflection table, which is exactly the failure the
// two words exist to turn into a clean false.
if (version != kProgramArtifactsCodecVersion) return false;
if (sizeEcho != kLinkArtifactsSizeEcho) return false;
ReadValue(in, link);
ReadValue(in, spirv);
if (!in.Ok) {
link = LinkArtifacts{};
spirv = SpirvArtifacts{};
return false;
}
// Trailing bytes are a mismatch too: the format accounts for every byte it writes, so
// anything left over means the reader and the writer disagree about the shape and the
// agreement so far was luck.
if (in.Pos != in.Size) {
link = LinkArtifacts{};
spirv = SpirvArtifacts{};
return false;
}
// Never written, never read, and stated here so it cannot be added by reflex.
link.program = nullptr;
return true;
}
} // namespace MobileGL::MG_State::GLState
@@ -0,0 +1,76 @@
// MobileGL - MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.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 "ProgramArtifacts.h"
// The reflection ARCHIVE's serializer (P4a, D-H2): create_shader_state's payload is per-stage
// SPIR-V plus LinkArtifacts + SpirvArtifacts, whole structs, and until now nothing could turn
// those into bytes. Every VisitFields comment in ProgramArtifacts.h said "(and its serializer
// when one exists)"; this is it.
//
// IT LIVES BESIDE THE HEADER RATHER THAN INSIDE IT, deliberately: ProgramArtifacts.h carries
// the check_include_closure.py "artifacts-header" probe, which pins that the header is
// glslang-free by symbol and reaches no ShaderObject, no SpvcSession, no Config and no
// MG_Backend. A codec inside it would have to be inspected against that probe on every edit;
// a codec beside it leaves the probe untouched, and this file is compiled only in a push
// build (the root CMakeLists.txt appends it inside `if (MOBILEGL_PIPE_PUSH)`).
//
// WHEN IT ACTUALLY RUNS, and the answer is "not on the monolith hot path at all". In monolith
// the archive does not travel: MGPProgramDesc's seven blob refs are declared with Size 0 -
// "this record does not declare its blob" - and MGPipeApplyCreateShaderState takes the two
// structs by pointer beside the record, so the applier reads the frontend's own archive and
// this codec is never called. The VERIFY build is where it is exercised, and it is exercised
// as LIVE CODE WITH A GATE rather than as dead code with a unit test: the applier serialises,
// deserialises and field-compares before storing, and a mismatch is
// Fatal{PipeVerifyDiffer, "program-archive"}. Under split, P5 is what makes it the transport's
// path.
//
// THE FORMAT, and every part of it is a refusal rather than a guess:
// * a VERSION word first, and a MGL_LINKARTIFACTS_SIZE echo second, so a struct that gained
// a field and a codec that did not is a MISMATCH AT READ TIME rather than a silent
// truncation that deserialises garbage into the tail of a reflection table;
// * length-prefixed everything - strings, vectors, maps, sets - with the count checked
// against the bytes that remain before a single element is allocated, so a corrupt count
// cannot turn into a four-billion-element resize;
// * little-endian, which is asserted rather than assumed;
// * and `LinkArtifacts::program` is NEVER visited. It is the live glslang TProgram, it is
// null for every archived instance by construction, and VisitFields deliberately omits it
// (57 of the 58 members). Decode leaves it null.
namespace MobileGL::MG_State::GLState {
// Bumped whenever the byte format changes in a way a previous reader would misread. A
// reader that sees a different word REFUSES; it never tries to guess a layout.
inline constexpr Uint32 kProgramArtifactsCodecVersion = 1;
// Appends the archive to `out` (which is not cleared, so a caller may frame it). Never
// fails: everything it walks is owned plain data.
void EncodeProgramArtifacts(const LinkArtifacts& link, const SpirvArtifacts& spirv,
Vector<Uint8>& out);
// Replaces `link` and `spirv` with what `bytes` describes. Returns false - with both
// outputs left in a defined, default state - for a truncated stream, a version mismatch, a
// struct-size mismatch, or trailing bytes the format does not account for. `link.program`
// is always null on return.
Bool DecodeProgramArtifacts(const Uint8* bytes, SizeT size, LinkArtifacts& link,
SpirvArtifacts& spirv);
// How many fields a type's VisitFields table actually visits. The codec walks exactly that
// table, so this is what pins "the codec did not quietly grow an arm of its own" - most of
// all for LinkArtifacts, whose 58th member is the live TProgram the table omits. It is a
// runtime count rather than a static_assert because VisitFields needs an INSTANCE and
// these structs carry strings, vectors and maps: none of them is a constant expression.
// ProgramArtifactsCodecTest is where it is asserted.
template <class T>
inline SizeT ProgramArtifactsVisitedFieldCount() {
T probe{};
SizeT count = 0;
VisitFields(probe, [&count](const char*, auto&) { ++count; });
return count;
}
} // namespace MobileGL::MG_State::GLState
@@ -14,6 +14,8 @@
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <MG_Pipe/PipeMutation.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
const char* kDefaultFragmentShaderSource = R"(#version 460 core
layout(location = 0) out vec4 FragColor;
@@ -28,7 +30,34 @@ 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).
//
// P4a D-I1: the notice is no longer raised directly - it is step 2 of the ONE
// client-side death helper for this kind, which emits delete_shader_state first,
// raises the notice second and frees the slot last. The client mints the ShaderCso, so
// the client is where its death has to be spoken from: a backend death-ops table is a
// redundant, idempotent SECOND path, and under a backend that installs none it was
// previously the ONLY one, which is how a slot leaks for the life of the process.
//
// AN ORDINARY PROGRAM AND A PIPELINE COMPOSITE TAKE THIS SAME LINE. A composite is an
// ordinary ProgramObject with its own lifetime id, its slot merely comes out of the
// reserved band, and its OTHER release path - the pipeline cache dropping it when the
// draw-program signature moves - goes through the same helper. Whichever runs second is
// a proven no-op, because the slot allocator refuses a slot that is not live at that
// generation.
MG_Pipe::MGPipeEmitShaderCsoDestroyAndFree(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.
@@ -1111,6 +1111,23 @@ namespace MobileGL::MG_State::GLState {
return Artifacts();
}
#if MOBILEGL_PIPE_PUSH
// The phase-B twin of GetLinkReflection, and it exists for exactly one caller:
// create_shader_state publishes the WHOLE SpirvArtifacts beside the whole
// LinkArtifacts, because the archive the far side deserialises into is those two
// structs and nothing else. The granular getters above (GetGeneratedSpirv,
// GetSpirvStatus, UsesNativeFloat64, PointSizeDemoted, GetSpirvValidationEnabled,
// GetUBOSize) cover what the GL layer needs and deliberately do not reach
// reservedNumSamplesOffset or uniformOffsets, which the descriptor does need.
//
// PUSH-ONLY, so a pull build's symbol set and this class's layout are byte-for-byte
// what they were: an inline const-ref accessor adds nothing to a pull build that is
// never compiled into one. It joins both phases, like every other Spirv() reader.
const SpirvArtifacts& GetSpirvReflection() const {
return Spirv();
}
#endif
static Bool IsValidUniformLocation(const LinkArtifacts& artifacts, Int location) {
if (location < 0 || location > static_cast<Int>(artifacts.maxUniformLocation)) return false;
if (static_cast<SizeT>(location) >= artifacts.uniformIndexInTProgram.size()) return false;
@@ -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,11 @@
#include "RenderbufferObject.h"
#include <MG_Util/Metrics/TextureMetrics.h>
#include <MG_State/GLState/StateObjectDeathNotice.h>
// The contract's own door, exactly as the texture half takes it (c0b): the four renderbuffer
// hooks this file calls are declared in MG_Pipe/PipeMutation.h and defined in
// MG_Impl/Pipe/PipeFill.cpp, so no MG_State translation unit includes the client's emitter.
#include <MG_Pipe/PipeMutation.h>
#include <atomic>
@@ -24,7 +29,35 @@ namespace MobileGL {
return g_nextRenderbufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
RenderbufferObject::RenderbufferObject(Uint externalIndex) : m_externalIndex(externalIndex) {}
RenderbufferObject::RenderbufferObject(Uint externalIndex) : m_externalIndex(externalIndex) {
#if MOBILEGL_PIPE_PUSH
// P4a D-D1: resource_create from the constructor, carrying no storage. A
// renderbuffer is an INDEPENDENT class on the wire - it shares MGPResourceDesc's
// shape with textures and buffers and nothing else - and its handle is minted
// whatever the subsystem bitmask says, because MGPSurface::Res names it out of the
// framebuffer subsystem.
// TWO CALLS AND NOT ONE (c0b): the mint is unconditional in a push build
// because a renderbuffer is named by handle out of the framebuffer subsystem
// whether or not its own family is switched on; the create is what the gate in
// PipeFill.cpp decides.
MG_Pipe::MGPipeMintRenderbufferHandle(*this);
MG_Pipe::MGPipeEmitRenderbufferResourceCreate(*this);
#endif
}
#if MOBILEGL_PIPE_PUSH
RenderbufferObject::~RenderbufferObject() {
// P4a D-I1, the fixed three-step order: the wire delete first (published-gated,
// because a slot is not evidence of a record), then the death notice - the P2
// step-e2 announcement that used to stand here alone, raised while the handle
// still resolves - and the slot last. Steps 2 and 3 are the contract's helper;
// step 1 is this package's, one statement earlier, because the helper's file
// belongs to the contract package for the whole phase.
// All three steps are the contract's helper (c0b); v1's separate step-1 call
// is deleted, not kept, for the reason ~TextureObjectBase states in full.
MG_Pipe::MGPipeEmitRenderbufferDestroyAndFree(m_lifetimeId);
}
#endif
Uint RenderbufferObject::GetExternalIndex() const {
return m_externalIndex;
@@ -81,17 +114,53 @@ namespace MobileGL {
void RenderbufferObject::SetInternalFormat(TextureInternalFormat format) {
m_internalFormat = format;
m_componentSizes = MG_Util::GetComponentSizesForInternalFormat(format);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
void RenderbufferObject::AllocateStorage(IntVec2 size) {
m_width = size.x();
m_height = size.y();
m_allocated = true;
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
void RenderbufferObject::SetSamples(Int samples) {
m_samples = samples;
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
#if MOBILEGL_PIPE_PUSH
// D-D2: THE RENDERBUFFER PUBLICATION HOLE, CLOSED BY EMISSION AND NOT BY A NEW
// VERSION. These three setters bump no version and raise no notice, so
// `glBindRenderbuffer; glRenderbufferStorage(newSize)` on an attached renderbuffer
// was invisible to everything downstream. Emitting from the storage entry point
// closes the RESOURCE half; a version counter here would resize the pull build's
// object and break G1.
//
// THE FRAMEBUFFER HALF IS THE AGGREGATE BUMP BELOW (P4a fable seam F-3), and the
// sentence that used to end the paragraph above - "widening the shutter would fire
// the framebuffer emission on an unrelated renderbuffer write" - was the seam:
// set_framebuffer_state inlines an attachment's InternalFormat, extent and Samples at
// emission (D-C1), so re-storaging an ATTACHED renderbuffer left the framebuffer
// record - and the handle arm's four cross-object masks - describing the previous
// storage while the resource record described the new one. The bump costs one
// framebuffer re-emission per storage definition, whether or not the object is
// attached, which the emitter's content hash suppresses when nothing it inlines
// moved; it is not a counter on this object.
//
// The emitter dedupes on the built descriptor, so glRenderbufferStorage's three-setter
// sequence publishes once rather than three times.
void RenderbufferObject::PipePublishDescriptor() {
MG_Pipe::MGPipeEmitRenderbufferResourceRespecify(*this);
MGP_NOTE_AGGREGATE(FramebufferAttachment);
}
#endif
} // namespace GLState
} // namespace MG_State
} // namespace MobileGL
@@ -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);
@@ -53,6 +59,13 @@ namespace MobileGL {
private:
static Uint64 AllocateLifetimeId();
#if MOBILEGL_PIPE_PUSH
// P4a D-D2: resource_respecify, from every storage-defining setter. Non-virtual
// and push-only, so the pull build's object layout is untouched (P4a's
// admitted-resize set is EMPTY); defined in RenderbufferObject.cpp, which is the
// one translation unit that includes the client emitter.
void PipePublishDescriptor();
#endif
Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = AllocateLifetimeId();
@@ -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,35 @@ 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).
//
// P4a D-I1: the notice is no longer raised directly - it is step 2 of the ONE
// client-side death helper for this kind, which emits delete_sampler_state
// first, raises the notice second and frees the slot last. Making the client
// the only death path is what stops a slot leaking under a backend that
// installs no death-ops table at all, and the backend's own notice becomes a
// redundant, idempotent second path rather than the only one.
//
// FOR A CONTENT-ADDRESSED SAMPLER CSO THIS HELPER CORRECTLY FREES NOTHING, and
// that is the design rather than a gap: the CSO belongs to a VALUE, not to this
// object (two identical SamplerObjects share one), so it is allocated with no
// lifetime id, the helper resolves nothing for this one, and the only death
// path for that slot is the CSO cache's LRU eviction - which is client-side and
// therefore backend-neutral on day one. What still goes out, unconditionally
// and exactly as before, is the notice.
MG_Pipe::MGPipeEmitSamplerCsoDestroyAndFree(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 +63,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,17 @@
#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>
// NO MG_State TRANSLATION UNIT SEES THE CLIENT'S EMITTER ANY MORE (c0b, ID-13). v1 included
// MG_Impl/Pipe/TextureEmit.h here and in RenderbufferObject.cpp because at the contract tag
// MG_Pipe/PipeMutation.h carried no texture row; it now declares the four mints, the nine
// emissions and the publication latch, so this file sees a DECLARATION exactly as
// BufferObject.cpp does and the closure gate's mutation-header probe has nothing to find.
// The three PipePublish* helpers stay on TextureObjectBase so the cube's, the view's and the
// buffer texture's translation units keep calling an inherited member.
namespace MobileGL {
namespace MG_State {
@@ -25,6 +34,84 @@ namespace MobileGL {
return s_nextTextureLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
#if MOBILEGL_PIPE_PUSH
TextureObjectBase::~TextureObjectBase() {
// P4a D-I1: BACKEND-NEUTRAL FROM DAY ONE, and the three-step order is fixed.
//
// 1. the wire delete FIRST - it drops the applier's record while the record
// still exists, so a recycled slot cannot inherit a field. Published-gated
// rather than slot-gated: a backend twin table mints a slot through
// MGPipeSlots().Acquire whether or not the subsystem ever asked this client
// to emit a create, and a resource_destroy on such a handle is a refused
// call the applier counts and asserts on;
// 2. the death notice SECOND - it resolves the handle through the allocator,
// and a backend told after the free could no longer find its twin. This is
// the P2 step-e2 announcement that used to stand here alone: the last
// SharedPtr to this object dropping, not the glDelete* that only marks the
// name and leaves a still-bound object very much alive;
// 3. the slot LAST, and a double free on a stale generation is a proven no-op.
//
// ALL THREE STEPS ARE THE CONTRACT'S HELPER (c0b): it reads the publication
// latch, emits the resource_destroy itself, raises the notice while the handle
// still resolves and frees the slot last. v1 emitted step 1 from a second
// statement here because at the tag the helper hard-coded `published = false`;
// that statement is deleted rather than kept, since a second delete for a
// record the helper has already dropped is a refused call the applier asserts
// on. The SamplerViewCso minted off this same lifetime id goes with it. The
// BUILT-IN SAMPLER does not: it is a real SamplerObject with its own lifetime
// id and its own destructor, which takes the same helper shape.
MG_Pipe::MGPipeEmitTextureDestroyAndFree(m_lifetimeId);
}
// ---- P4a's three client emission points (see TextureObject.h) ----
void TextureObjectBase::PipePublishDescriptor() {
MG_Pipe::MGPipeEmitTextureResourceRespecify(*this, MG_Pipe::MGPipeTextureRespecifyScope::WholeResource,
0, 0);
// AND THE FRAMEBUFFER AGGREGATE MOVES (P4a fable seam F-3). The resource record
// above is only half of what a storage definition changes: set_framebuffer_state
// INLINES an attachment's InternalFormat, TextureTarget, extent, Samples and
// Complete at emission (D-C1), so redefining the storage of a texture that is
// ATTACHED changed those fields with nothing bit 11 reads moving - the format
// and shape setters bump the two TEXTURE aggregates and never the attachment
// one, and no path from a texture reaches its framebuffers. The handle arm then
// answered its four cross-object masks from the stale copy while the legacy arm
// re-read the frontend at the same re-sync. This is the one funnel every
// storage-defining entry point takes (see AllocateStorage), so the bump lives
// here and not per setter, it is push-only like the rest of this block, and it
// over-fires the framebuffer bit once per storage definition of an unattached
// texture - at load time, where a 304-byte hash is nothing.
MGP_NOTE_AGGREGATE(FramebufferAttachment);
}
void TextureObjectBase::PipePublishLevelDescriptor(TextureUploadTarget uploadTarget, Uint mipmapLevel) {
// ONE level was (re)allocated: only that level's pending upload is against
// storage that is gone (P4a final review C-1). Every other level's stays.
MG_Pipe::MGPipeEmitTextureResourceRespecify(*this, MG_Pipe::MGPipeTextureRespecifyScope::OneLevel,
static_cast<Uint32>(uploadTarget),
static_cast<Uint32>(mipmapLevel));
MGP_NOTE_AGGREGATE(FramebufferAttachment); // an attached level's extent is inlined (F-3)
}
void TextureObjectBase::PipePublishTruncatedDescriptor(TextureUploadTarget uploadTarget, Uint levelCount) {
// The chain was cut at `levelCount`: the levels above the cut are gone with their
// pending uploads, the levels below it are untouched and keep theirs.
MG_Pipe::MGPipeEmitTextureResourceRespecify(*this, MG_Pipe::MGPipeTextureRespecifyScope::LevelsFrom,
static_cast<Uint32>(uploadTarget),
static_cast<Uint32>(levelCount));
MGP_NOTE_AGGREGATE(FramebufferAttachment);
}
void TextureObjectBase::PipePublishParams() {
MG_Pipe::MGPipeEmitTextureParams(*this);
}
void TextureObjectBase::PipeNoteLevelDirty(TextureUploadTarget uploadTarget, Uint mipmapLevel) {
MG_Pipe::MGPipeNoteTextureLevelDirty(*this, static_cast<Uint32>(uploadTarget),
static_cast<Uint32>(mipmapLevel));
}
#endif
void TextureObjectBase::BumpShapeVersion() {
++m_shapeVersion;
// Shape is what mipmap-completeness is computed from, and completeness decides
@@ -53,6 +140,34 @@ namespace MobileGL {
m_sampler->SetWrapT(SamplerWrapMode::ClampToEdge);
m_sampler->SetWrapR(SamplerWrapMode::ClampToEdge);
}
#if MOBILEGL_PIPE_PUSH
// P4a D-D1: 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_framebuffer_state and set_sampler_views name this texture by handle
// out of two different subsystems.
//
// NOTHING THE DERIVED CLASS IMPLEMENTS IS TOUCHED HERE and that is a
// correctness requirement rather than a style: the derived object does not exist
// yet, so ITextureObject::GetStorageType and ::GetUploadTargets - PURE, with no
// body on this base - would be undefined behaviour. The emitter reads GetTarget()
// and GetExternalIndex(), which TextureObjectBase itself overrides and which
// therefore dispatch to this class's own bodies over members the mem-init list
// has already written; the storage kind is derived from the target, which is
// exact (TextureObjectBuffer is the only class that reports Buffer and
// TextureBuffer is the only target it is constructed with).
//
// TWO CALLS AND NOT ONE (c0b): the MINT is unconditional in a push build -
// set_framebuffer_state and set_sampler_views name this texture by handle out of
// two other subsystems, so gating it would make them emit null handles in exactly
// the A/B arm that exists to isolate the families - and the CREATE is what the
// subsystem gate in PipeFill.cpp decides.
(void)target;
(void)externalIndex;
MG_Pipe::MGPipeMintTextureHandle(*this);
MG_Pipe::MGPipeEmitTextureResourceCreate(*this);
#endif
}
TextureInternalFormat TextureObjectBase::GetFormat() const {
@@ -99,6 +214,15 @@ namespace MobileGL {
m_internalFormat = format;
BumpShapeVersion();
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
// The format is BOTH halves: a descriptor field the backend allocates from and a
// parameter the swizzle / depth-stencil push reads, so both records move. It is
// also the last statement of both glTexBuffer entry points, which is what
// publishes a buffer texture's window without a call site in MG_Impl/GLImpl.
PipePublishDescriptor();
PipePublishParams();
#endif
}
Uint TextureObjectBase::GetExternalIndex() const {
@@ -124,6 +248,10 @@ namespace MobileGL {
m_sampler->SetBorderColor(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
const IntVec4& TextureObjectBase::GetBorderColorI() const {
@@ -138,6 +266,10 @@ namespace MobileGL {
m_sampler->SetBorderColorI(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
const UintVec4& TextureObjectBase::GetBorderColorUI() const {
@@ -152,6 +284,10 @@ namespace MobileGL {
m_sampler->SetBorderColorUI(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
BorderColorForm TextureObjectBase::GetBorderColorForm() const {
@@ -201,6 +337,10 @@ namespace MobileGL {
break;
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
void TextureObjectBase::SetSwizzleParamRGBA(const Vec4<TextureSwizzleParam>& values) {
@@ -208,6 +348,10 @@ namespace MobileGL {
m_swizzleParams = values;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
const UintVec2& TextureObjectBase::GetLevelRange() const {
@@ -225,7 +369,14 @@ namespace MobileGL {
m_levelRange.y() = m_levelRange.x();
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
BumpShapeVersion();
#if MOBILEGL_PIPE_PUSH
// The level range is PARAMS, not a descriptor field: the record carries
// BaseLevel / MaxLevel and the storage is untouched. The descriptor is deduped on
// its own bytes, so the shape bump above costs nothing here.
PipePublishParams();
#endif
}
void TextureObjectBase::SetMaxLevel(Uint maxLevel) {
@@ -236,7 +387,11 @@ namespace MobileGL {
m_levelRange.y() = maxLevel;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
BumpShapeVersion();
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
Bool TextureObjectBase::IsImmutable() const {
@@ -256,6 +411,15 @@ namespace MobileGL {
m_levelRange.y() = std::min(std::max(m_levelRange.y(), m_levelRange.x()), m_immutableLevels - 1);
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
// Immutable is a descriptor fact the backend reads - and NOT a request for an
// acknowledgement: MGPipeResourceRespecifyNeedsAck names the buffer target
// explicitly, because texture allocation is lazy in monolith and stays lazy in
// split. The level clamp above is params.
PipePublishDescriptor();
PipePublishParams();
#endif
}
Uint16 TextureObjectBase::GetTextureParamsVersion() const {
@@ -283,6 +447,7 @@ namespace MobileGL {
void TextureObjectBase::BumpContentVersion() {
++m_contentVersion;
MGP_NOTE_AGGREGATE(TextureContent);
}
Int TextureObjectBase::GetSamples() const {
@@ -292,6 +457,10 @@ namespace MobileGL {
void TextureObjectBase::SetSamples(Int samples) {
m_samples = samples;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
Bool TextureObjectBase::HasFixedSampleLocations() const {
@@ -301,6 +470,10 @@ namespace MobileGL {
void TextureObjectBase::SetFixedSampleLocations(Bool fixedSampleLocations) {
m_fixedSampleLocations = fixedSampleLocations;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
Uint64 TextureObjectBase::GetLifetimeId() const {
@@ -332,11 +505,27 @@ namespace MobileGL {
MipmapInput input) {
BumpShapeVersion();
m_textureStorage.AllocateLevel(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, input);
#if MOBILEGL_PIPE_PUSH
// AFTER the allocation, never before: BumpShapeVersion runs first and a descriptor
// built there would describe the level set this call is about to change. Every
// storage-defining GL entry point - glTexImage*, glCompressedTexImage*,
// glTexStorage*, glTextureView and the generated-mip storage grow - reaches
// storage through here, which is what makes the emission complete without one call
// site per entry point in MG_Impl/GLImpl. AND IT NAMES THE LEVEL (final review
// C-1): this call replaced ONE level's storage, and only that level's pending
// upload may go with it.
PipePublishLevelDescriptor(uploadTarget, mipmapLevel);
#endif
}
void TextureObjectWithOneMipmap::TruncateMipmapLevels(TextureUploadTarget uploadTarget, Uint levelCount) {
BumpShapeVersion();
m_textureStorage.TruncateToLevelCount(GetIndexOfTextureUploadTarget(uploadTarget), levelCount);
#if MOBILEGL_PIPE_PUSH
// The levels at and above the cut are gone; the ones below keep their pending
// uploads (final review C-1).
PipePublishTruncatedDescriptor(uploadTarget, levelCount);
#endif
}
void TextureObjectWithOneMipmap::UpdateMipmapSubData(TextureUploadTarget uploadTarget, Uint mipmapLevel,
@@ -352,8 +541,14 @@ namespace MobileGL {
Bool dirty) {
if (dirty) {
++m_contentVersion;
MGP_NOTE_AGGREGATE(TextureContent);
}
m_textureStorage.MarkDirty(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, dirty);
#if MOBILEGL_PIPE_PUSH
// THE DRAIN LIST HAS NO CLEAN ARM (see TextureEmit.h): a level that goes clean
// is collected at the next drain, whose first test is IsStorageDirty.
if (dirty) PipeNoteLevelDirty(uploadTarget, mipmapLevel);
#endif
}
Bool TextureObjectWithOneMipmap::IsStorageDirty(TextureUploadTarget uploadTarget, Uint mipmapLevel) const {
@@ -363,8 +558,16 @@ 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);
#if MOBILEGL_PIPE_PUSH
// THE DRAIN LIST IS KEYED ON THE STORAGE OWNER FOR FREE: TextureObjectView
// forwards this call to the OWNER's method after remapping the level and the
// region, so an upload through a view and an upload through the owner arrive here
// on the same object with the same owner-side coordinates.
PipeNoteLevelDirty(uploadTarget, mipmapLevel);
#endif
}
MipmapDirtyRegion TextureObjectWithOneMipmap::GetStorageDirtyRegion(TextureUploadTarget uploadTarget,
@@ -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,13 @@ namespace MobileGL::MG_State::GLState {
if (m_depthStencilTextureMode == mode) return;
m_depthStencilTextureMode = mode;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
// D-E3's whole point, at the one site that proves it: the depth-stencil mode of a
// texture that is ONLY the READ framebuffer's attachment reaches the driver, because
// set_texture_params is addressed by resource and is independent of every binding.
PipePublishParams();
#endif
}
protected:
@@ -191,6 +207,46 @@ namespace MobileGL::MG_State::GLState {
// otherwise invisible to such a memo (no bind moved).
void BumpShapeVersion();
#if MOBILEGL_PIPE_PUSH
// ---- P4a's client emission points (brief D-D1, D-D3, D-E1, D-I1) ----
//
// NON-VIRTUAL AND PUSH-ONLY, both deliberately: a virtual would grow the vtable and a
// member would grow the object, and P4a's admitted-resize set is EMPTY - every edit
// that reaches the pull build is inside this guard, so the pull build's symbol set is
// byte-for-byte the one it had before the phase.
//
// DECLARED HERE AND DEFINED IN TextureObject.cpp, which calls the contract's own hooks
// in MG_Pipe/PipeMutation.h - the same door BufferObject.cpp uses, and no MG_State
// translation unit sees MG_Impl/Pipe/TextureEmit.h at all (c0b, ID-13). They stay
// members rather than free calls so the cube's, the view's and the buffer texture's
// translation units keep calling an inherited helper.
//
// resource_respecify, WHOLE-RESOURCE scope: the format setter and the three parameter
// setters that move a DESCRIPTOR field without moving the shape (immutable levels,
// sample count, fixed sample locations), and a view's creation. The emitter dedupes
// this form on the built descriptor, so an over-call costs one 88-byte compare and
// never an extra record.
void PipePublishDescriptor();
// The PER-LEVEL and the CHAIN-CUT forms of the same call (P4a final review C-1). The
// applier keeps a pending-upload set per (uploadTarget, level) and drops the entries
// against the storage a respecify REPLACES - and the descriptor cannot tell it which:
// AllocateStorage is per level and TruncateMipmapLevels removes a tail, while the
// descriptor carries the base extent and the level count only. So the storage entry
// points state the scope themselves; the whole-resource form above is for the calls
// that really redefine the whole store. A per-level form is NOT deduped on the
// descriptor: a non-base level redefined at a new size moves no descriptor field, and
// the applier's box against the old level has to go regardless.
void PipePublishLevelDescriptor(TextureUploadTarget uploadTarget, Uint mipmapLevel);
void PipePublishTruncatedDescriptor(TextureUploadTarget uploadTarget, Uint levelCount);
// set_texture_params, from every mutator that bumps m_textureParamsVersion.
void PipePublishParams();
// The sub-data DRAIN LIST's append, on a level's first dirty mark. There is no clean
// arm: the contract's hook (MG_Pipe/PipeMutation.h) carries no `dirty` flag, and a
// level that goes clean is collected at the next drain, where !IsStorageDirty is the
// first test EmitOneLevel makes.
void PipeNoteLevelDirty(TextureUploadTarget uploadTarget, Uint mipmapLevel);
#endif
const Uint m_externalIndex;
const Uint64 m_lifetimeId;
const TextureTarget m_target = TextureTarget::Unknown;
@@ -7,6 +7,7 @@
// End of Source File Header
#include "TextureObject2DCube.h"
#include <MG_Pipe/PipeMutation.h>
namespace MobileGL {
namespace MG_State {
@@ -30,11 +31,21 @@ namespace MobileGL {
MipmapInput input) {
BumpShapeVersion();
m_textureStorage.AllocateLevel(GetIndexOfTextureUploadTarget(uploadTarget), mipmapLevel, input);
#if MOBILEGL_PIPE_PUSH
// AFTER the allocation, for TextureObjectWithOneMipmap's reason: BumpShapeVersion
// runs first and a descriptor built there would describe the level set this call
// is about to change. The FACE rides in `uploadTarget`, so the key the emitter
// drops is that face's level and no other face's (final review C-1).
PipePublishLevelDescriptor(uploadTarget, mipmapLevel);
#endif
}
void TextureObject2DCube::TruncateMipmapLevels(TextureUploadTarget uploadTarget, Uint levelCount) {
BumpShapeVersion();
m_textureStorage.TruncateToLevelCount(GetIndexOfTextureUploadTarget(uploadTarget), levelCount);
#if MOBILEGL_PIPE_PUSH
PipePublishTruncatedDescriptor(uploadTarget, levelCount);
#endif
}
void TextureObject2DCube::UpdateMipmapSubData(TextureUploadTarget uploadTarget, Uint mipmapLevel,
@@ -49,8 +60,14 @@ 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);
#if MOBILEGL_PIPE_PUSH
// SIX FACES, SIX BLOBS, SIX DRAIN KEYS: the upload target is the face, and it is
// what the sub-data record's Target byte carries beside the resource target.
if (dirty) PipeNoteLevelDirty(uploadTarget, mipmapLevel);
#endif
}
bool TextureObject2DCube::IsStorageDirty(TextureUploadTarget uploadTarget, Uint mipmapLevel) const {
@@ -60,8 +77,12 @@ 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);
#if MOBILEGL_PIPE_PUSH
PipeNoteLevelDirty(uploadTarget, mipmapLevel);
#endif
}
MipmapDirtyRegion TextureObject2DCube::GetStorageDirtyRegion(TextureUploadTarget uploadTarget,
@@ -29,6 +29,15 @@ namespace MobileGL {
void SetBufferRange(SizeT offset, SizeT size) {
m_bufferRangeOffset = offset;
m_bufferRangeSize = size;
#if MOBILEGL_PIPE_PUSH
// Both glTexBuffer entry points bind the backing buffer and then set the
// window, so this is the first statement at which the descriptor's
// BufferForTexBuffer / BufOffset / BufSize trio is complete. SetInternalFormat
// publishes again one statement later and is deduped away when the format did
// not move - which is exactly the case a re-attach of a DIFFERENT buffer at
// the same format would otherwise fall through.
PipePublishDescriptor();
#endif
}
SizeT GetBufferRangeOffset() const { return m_bufferRangeOffset; }
// Resolved against the buffer's current size, so kWholeBuffer tracks it.
@@ -79,6 +79,18 @@ namespace MobileGL::MG_State::GLState {
// to the view. GetImmutableLevels() forwards to the owner for the actual GL query, which
// GL 4.6 core 8.18 defines as the ORIGINAL texture's value.
SetImmutableLevels(numLevels);
#if MOBILEGL_PIPE_PUSH
// ViewOf, and it is published from HERE rather than left to SetImmutableLevels above,
// which early-returns when the composed level count happens to be the base class's
// current value - a degenerate view (the spec's min() composition narrowed to zero
// levels) would otherwise never publish the one field that makes it a view. The
// descriptor is deduped on its own bytes, so the ordinary case pays one compare.
//
// ONE HOP ALWAYS REACHES STORAGE: glTextureView composes a view-of-a-view onto the ROOT
// at creation, which is what the spec's additive min-level rule describes and what the
// assertion above pins, so the owner named here is never itself a view.
PipePublishDescriptor();
#endif
}
Uint TextureObjectView::GetImmutableLevels() const {
@@ -33,6 +33,15 @@ namespace MobileGL::MG_State::GLState {
Access = access;
Format = format;
++Version;
#if MOBILEGL_PIPE_PUSH
// P4a D-A4 / final review M-A: the EARLIEST producer of kMGPipeBindShaderImage. The
// ImageBindableHint the bit feeds is the prevention half of the texture-remint stall
// class (a texture the server knows may be image-bound is allocated image-bindable
// up front), so it has to reach the applier before the texture's first sync - at
// the bind, not at the next validate point's image walk. Push-only through the
// contract's door, like every other hook in this directory (G1).
if (Texture) MG_Pipe::MGPipeNoteTextureImageBound(*Texture);
#endif
}
};
@@ -140,7 +149,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

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