Compare commits

..
Author SHA1 Message Date
rereview 5175f9ccd5 [Docs] (MG_Remote): record R-15 and split the emit table's 71 slots into answered-locally, emitted and Fatal, with the forty-one null checks that are really capability probes 2026-09-11 13:23:46 -04:00
rereview 45b759e7a5 [Feat] (MG_Pipe): give resource_respecify's redefinition scope a wire carrier in the descriptor's two existing pad fields, so a per-level glTexImage cannot cross as a whole-resource redefinition and eat every other level's pending upload 2026-09-11 13:23:46 -04:00
rereview b4bbcc113a [Docs] (MG_Remote): CONTRACT-P5.md - the encoding, byte-carrier, field-ownership and role-ownership tables every P5 package is held to 2026-09-11 13:05:05 -04:00
rereview dd4afb0faf [Docs, Test] (MG_Remote, Wire): state the five sequence watermarks, the late-never-early batching rule and the pad-does-not-count rule on RingControl, and pin all three with five cases 2026-09-11 13:05:05 -04:00
rereview 72a4cba5ad [Fix, Test] (MG_Test): include the header that defines MOBILEGL_PIPE_POISON - seven emitter suites tested the macro without it, compiled the non-aborting arm, and turned eighteen expected refusals into aborts the moment a disaggregated build armed it 2026-09-11 13:05:05 -04:00
rereview 418e6f50be [Feat] (MG_Remote): land the P5 contract headers and their named-Fatal stubs so all seven parallel packages compile and link on day one 2026-09-11 13:05:05 -04:00
rereview 1ee6c8953a [Build] (CMake): add MOBILEGL_BUILD_DISAGGREGATED_INPROC, make DISAGGREGATED imply PIPE_PUSH, and list the new MG_Remote Wire/Client/Server sources 2026-09-11 13:05:05 -04:00
rereview ec20e7001d [Feat] (Config, ConfigLoader): parse MOBILEGL_TRANSPORT and the MOBILEGL_IPC_* family entirely behind the disaggregated option, so the pull build's symbol set cannot move 2026-09-11 13:05:05 -04:00
rereview 41b4f8dfe3 [Fix] (MG_Pipe, gen_pipe): export the per-opcode call flags and give GetCaps and CreateSamplerState the kHasBlob their payloads have always owned 2026-09-11 13:05:05 -04:00
rereview a29807cc48 [Docs] (Disaggregated): record P4a as landed - the fourteen family calls and six re-keyed kinds, the seven contract corrections and the defect each one closed, the three-arm A/B on a pinned Adreno 830 with active cooling, the upload shape that did not move, and the seam taxonomy the next brief has to carry 2026-09-11 11:37:08 -04:00
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
115 changed files with 33303 additions and 432 deletions
+76 -9
View File
@@ -601,7 +601,26 @@ jobs:
# only in the pull `integration` job, where a MOBILEGL_PIPE_PUSH value steers nothing
# (Config.h declares the field inside the push guard) and both arms are the same legacy path.
# A lane that cannot go red where it is installed is not a gate (ROADMAP.md:7).
- name: The handle-ABA, CSO and P3a subsystem controls (G8, G10, G12)
# 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"
@@ -612,7 +631,7 @@ jobs:
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' \
-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
@@ -771,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
@@ -1557,12 +1585,18 @@ jobs:
# Deliberately independent of build-linux: these are source-level gates, they take
# seconds, and a broken build must not hide a drifted interface.
env:
# P3a's base ref, for the G5 region gate below. It is 44c2b5cf and NOT the workflow's
# baseline_sha input: that input is the SYMBOL baseline (087685d1, P1's G1 reading) and it
# is empty on a push, whereas this gate asks "did the buffer pool, the deferred-release
# drain and the three rings move since the phase started". INTEGRATOR-DECISIONS ID-1 is
# where the ref comes from.
BASELINE: "44c2b5cf"
# 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
@@ -1664,6 +1698,39 @@ jobs:
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
# --strict when the documents settle.
+64
View File
@@ -21,6 +21,13 @@ option(MOBILEGL_IOS "Build MobileGL for iOS instead of macOS when
# That emptiness is one of the two byte-level equalities the plan's validation
# gates keep (section 10.3).
option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" OFF)
# The CI / debugging shape (ARCHITECTURE.md:581): both roles in ONE process, talking over the
# same SEG_CMD ring and the same G3 codec a spawned server would use. It IMPLIES
# MOBILEGL_BUILD_DISAGGREGATED (below) and additionally admits the role-isolation shims that
# only make sense when the two roles share an address space. It is a SUPERSET, never a
# substitute: MOBILEGL_TRANSPORT=inproc is what selects the shape at run time, and this option
# only decides whether the shims are compiled in.
option(MOBILEGL_BUILD_DISAGGREGATED_INPROC "Compile the in-process (one-process, two-role) split shims; implies MOBILEGL_BUILD_DISAGGREGATED" OFF)
option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery-chain executable (Android only)" OFF)
# The PipeInputs strangler (ARCHITECTURE.md 9.2). OFF is the pull build and must stay
# byte-identical to a tree without either option: MGB_CTX is the live GLContext, no
@@ -445,6 +452,17 @@ set(SOURCE_FILES
# option OFF not one file here is compiled and no include path is added.
# ---------------------------------------------------------------------------
# MOBILEGL_BUILD_DISAGGREGATED_INPROC implies MOBILEGL_BUILD_DISAGGREGATED. A normal
# variable, not a forced cache write, for the reason the two implications below give: an
# operator's cache entry stays theirs and only this configure is shadowed. Ordered BEFORE the
# flatbuffers guard so that a missing submodule turns BOTH off together - an INPROC build
# with the transport shadowed off would compile a role-isolation shim over no roles.
if (MOBILEGL_BUILD_DISAGGREGATED_INPROC AND NOT MOBILEGL_BUILD_DISAGGREGATED)
message(STATUS "MobileGL: MOBILEGL_BUILD_DISAGGREGATED_INPROC=ON forces "
"MOBILEGL_BUILD_DISAGGREGATED ON for this configure")
set(MOBILEGL_BUILD_DISAGGREGATED ON)
endif()
# FlatBuffers is a submodule and its runtime is header-only. Guard both ways:
# a checkout without the submodule must configure and build, just without the
# disaggregated shape, rather than fail with a missing-header error a hundred
@@ -462,6 +480,24 @@ if (MOBILEGL_BUILD_DISAGGREGATED AND
# all. Shadowing the cache entry for this configure only keeps the operator's ON where it
# was, so the next configure - with the submodule there - honours it.
set(MOBILEGL_BUILD_DISAGGREGATED OFF)
# And with it the shim option, or the `-DMOBILEGL_BUILD_DISAGGREGATED_INPROC=1` below
# would still be defined over a build with no MG_Remote in it at all.
set(MOBILEGL_BUILD_DISAGGREGATED_INPROC OFF)
endif()
# MOBILEGL_BUILD_DISAGGREGATED implies MOBILEGL_PIPE_PUSH (P5 c0). The split path IS the
# pushed path: MG_Remote's server decodes records into the MGPipeApply* entry points, which
# live in MG_Pipe/PipeApply.cpp, which the PIPE_PUSH block below is what compiles. Without
# this, `-DMOBILEGL_BUILD_DISAGGREGATED=ON` alone configures and then fails to link the
# applier - and the shape it fails in (MG_Remote compiled, no applier) is indistinguishable
# at the CMake level from a legitimate transport-only build, which is why it is stated here
# rather than left to whoever hits the link error. Same normal-variable form as the two
# implications above.
if (MOBILEGL_BUILD_DISAGGREGATED AND NOT MOBILEGL_PIPE_PUSH)
message(STATUS "MobileGL: MOBILEGL_BUILD_DISAGGREGATED=ON forces MOBILEGL_PIPE_PUSH ON for "
"this configure: the split path decodes into the MGPipe applier, and the "
"applier is what MOBILEGL_PIPE_PUSH compiles")
set(MOBILEGL_PIPE_PUSH ON)
endif()
# MOBILEGL_PIPE_VERIFY implies MOBILEGL_PIPE_PUSH: the comparator compares the pushed block
@@ -491,6 +527,12 @@ if (MOBILEGL_PIPE_PUSH)
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()
@@ -510,6 +552,28 @@ if (MOBILEGL_BUILD_DISAGGREGATED)
# Keeps MG_Util/Debug/Log.h - and through it the GL frontend's
# umbrella header - out of the header-only wire code (WireLog.h).
MobileGL/MG_Remote/Transport/WireLog.cpp
# ---- P5: the three new directories ------------------------------
# Wire/ the G3 codec: MGPWireRec_* in and out of SEG_CMD, blobs and
# var-tails in and out of SEG_STAGE. [w1]
# Client/ the emitting role: session, the 69-slot emit table, the
# caps mirror. [c1]
# Server/ the applying role: session, the applier bridge onto the
# existing MGPipeApply* free functions, the apply thread. [v1]
#
# Every file below lands in P5 as a HEADER plus a .cpp of named
# Fatal stubs, so that all seven P5 packages compile and link on day
# one against signatures that cannot then move under them. A stub is
# MGLOG_F + std::abort, never a silent no-op: an unimplemented
# emitter that returns quietly is how a split lane runs monolith and
# goes green (ARCHITECTURE.md 10.3).
MobileGL/MG_Remote/CapsCodec.cpp
MobileGL/MG_Remote/Wire/PipeWireCodec.cpp
MobileGL/MG_Remote/Client/ClientSession.cpp
MobileGL/MG_Remote/Client/EmitTables.cpp
MobileGL/MG_Remote/Client/CapsMirror.cpp
MobileGL/MG_Remote/Server/ServerSession.cpp
MobileGL/MG_Remote/Server/PipeApplier.cpp
MobileGL/MG_Remote/Server/ServerLoop.cpp
)
endif()
+116 -5
View File
@@ -322,16 +322,28 @@ namespace MobileGL::MG_Config {
// 0 - the only shipped value until the migration lands - is "pull everything",
// i.e. exactly today's behaviour, and is the default of a PULL build, where the
// knob is meaningless anyway. A PUSH build defaults to every subsystem migrated so
// far (MG_Pipe::kMGPipeSubsystemsMigratedAtP3a), so MOBILEGL_PIPE_PUSH=0 in the
// environment is the all-pull control and 0x7f (kMGPipeSubsystemsMigratedAtP2) is
// the "P2 only" control P3a's A/B is run against. Accepts decimal or 0x-prefixed
// hex, and operators pass it as hex, so the bits are listed here (MG_Pipe/MGPipe.h
// owns them):
// 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.
@@ -402,4 +414,103 @@ namespace MobileGL::MG_Config {
String PipeStatsFile;
};
extern FeaturesTable Features;
// ---------------------------------------------------------------------------------
// P5: the transport selector and the MOBILEGL_IPC_* family (ARCHITECTURE.md 16, 附 A)
// ---------------------------------------------------------------------------------
//
// MOBILEGL_TRANSPORT = monolith | inproc | spawn | unix:<path> | pipe:<name>.
//
// WHY `Transport` IS NOT A FeaturesTable MEMBER. ARCHITECTURE.md:580 requires that with
// MOBILEGL_BUILD_DISAGGREGATED=OFF it be a `constexpr Monolith`, so that the single hook
// in MG_Backend/Init.cpp compiles away entirely rather than becoming a branch nobody can
// take. A FeaturesTable member is a runtime field in every build, which is the opposite
// of that; it would also resize MG_Config::Features and break G1 (the pull build's
// symbol set must not move) for the same reason the MOBILEGL_PIPE_VERIFY knobs above sit
// behind their own #if.
//
// ONE CONSEQUENCE, STATED SO IT IS NOT REDISCOVERED: in a build without the option,
// MOBILEGL_TRANSPORT=inproc is ACCEPTED BY THE ENVIRONMENT AND SILENTLY IGNORED - the
// parser below does not exist to complain about it, and putting a complaint in the
// unconditional part of ConfigLoader would move a pull-build symbol. That is the exact
// shape of "the split lane ran monolith and went green", so the gate against it is a
// BUILD-level check, not a runtime one: `nm --defined-only libMobileGL.so | grep -i
// MG_Remote` must be non-empty in build-split (CONTRACT-P5.md table 3, and the CI job
// P5 adds beside build-linux-verify).
enum class TransportMode : Uint8 {
Monolith = 0, // today's in-library backend; no MG_Remote object is constructed
InProcess = 1, // P5: a real apply thread in this process, over the same G3 codec
Spawn = 2, // P6: fork/exec MobileGLServer, socketpair
UnixSocket = 3,// P6: connect to an existing AF_UNIX endpoint (Endpoint = <path>)
NamedPipe = 4, // P6: Windows named pipe (Endpoint = <name>)
};
#if MOBILEGL_BUILD_DISAGGREGATED
// Parsed once by MG_ConfigLoader::Init(). Defaults to Monolith even here: building the
// transport in is not the same as using it, and every existing lane of a build-split
// must keep running monolith unless it is asked for one.
extern TransportMode Transport;
// The <path> of `unix:` / the <name> of `pipe:`. Empty for the other three modes.
extern String TransportEndpoint;
// The MOBILEGL_IPC_* family. A separate table rather than more FeaturesTable members,
// for the G1 reason above and because every field here is meaningless without the
// transport: a build that cannot reach the MG_Remote code cannot honour one of them.
//
// P5 lands exactly the knobs P5's own packages read. A later phase's knob is added HERE,
// through the integrator, and not invented at its call site - ARCHITECTURE.md:615 holds
// the full planned inventory (PRESENT_CREDIT, POLL_ESCALATE, SHADOW_SHM,
// INLINE_PAYLOADS, TRACE, ATTACH, RESPAWN, IDLE_EXIT_S), and every one of those belongs
// to P6 or later.
struct IpcTable {
// MOBILEGL_IPC_SERVER_PATH: where to find libMobileGLServer. P6 consumes it; P5
// lands the parse because t1's ctest ENVIRONMENT blocks and add_trace_replay_test's
// SPLIT variant already carry it, and an environment variable that nothing parses is
// indistinguishable from one that is parsed and ignored.
String ServerPath;
// MOBILEGL_IPC_RING_MB: SEG_CMD size. A RECORD MAY BE AT MOST HALF OF THIS
// (RingProducer::MaxRecordBytes), so 8 MiB caps one record at 4 MiB; R-10 makes the
// codec publish a max-record-bytes counter rather than assume that is enough.
Uint32 RingMb = 8;
// MOBILEGL_IPC_STAGE_MB: SEG_STAGE size. Every blob and every var-tail's bytes live
// here (R-10: no chunking in P5, so nothing may exceed it).
Uint32 StageMb = 32;
// MOBILEGL_IPC_SPIN_US: spin before parking on a doorbell, either direction.
Uint32 SpinUs = 50;
// MOBILEGL_IPC_PERSISTENT_BLOCK_KB: block granularity of the persistent-map push.
// 0 IS A NEGATIVE CONTROL, NOT "unlimited": it disables the push, and
// PersistentCoherentMapScenario must go RED under it (exit gate E3(a)).
Uint32 PersistentBlockKb = 64;
// MOBILEGL_IPC_ADOPT_TIER: 2 = emulate (client keeps the shadow and pushes), which
// is the only tier P5 implements and the reason persistent-map-push can be non-zero
// at all (R-6). 0 and 1 parse and are Fatal at use with "P11"; they exist now so the
// negative control has a spelling the day P11 writes it.
Uint32 AdoptTier = 2;
// MOBILEGL_IPC_VERB_BARRIER: 1 = the client blocks at every verb boundary until
// appliedSeq reaches its emitSeq (R-1). 0 is the negative control: it is EXPECTED to
// be red, because 31 of the 63 PipeInputs fields are still pulled from a live
// GLContext by the client's residual fill and a free-running queue lets the server
// read a FUTURE value of them.
Uint32 VerbBarrier = 1;
// MOBILEGL_IPC_STRICT_ERRORS: promote a BARRIER-PULLED field read - and, in a split
// build, the seven sticky forwards that are otherwise exempt - from "count it in
// rsp" to Fatal (R-7.3).
Bool StrictErrors = false;
// MOBILEGL_IPC_AUDIT: after a record retires, the server fills the SEG_STAGE bytes
// it referenced with 0xDD (R-2.5). This is the ONLY mechanical control that an
// inproc implementation did not quietly keep using a pointer past its lifetime.
Bool Audit = false;
// MOBILEGL_IPC_SERVER_AFFINITY: `auto` (the default, big-core detection borrowed
// from ShaderCompilePool), `off`, or an explicit CPU mask. Kept as the raw string
// because the resolved mask is logged by whoever starts the apply thread, and the
// string is what an operator typed.
String ServerAffinity = "auto";
};
extern IpcTable Ipc;
#else
// The whole point: in a build without MG_Remote this folds at compile time, so
// `if (MG_Config::Transport != MG_Config::TransportMode::Monolith)` in Init.cpp is a
// discarded statement and the pull build gains no symbol, no branch and no byte.
inline constexpr TransportMode Transport = TransportMode::Monolith;
#endif
} // namespace MobileGL::MG_Config
+109 -6
View File
@@ -8,9 +8,9 @@
#include "Config.h"
#if MOBILEGL_PIPE_PUSH
// For kMGPipeSubsystemsMigratedAtP3a, the push build's PipePush default (the P2 constant
// beside it is the phase-by-phase control, not the default). Push-only, so the pull
// build's translation unit is unchanged.
// 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
@@ -25,6 +25,15 @@ namespace MobileGL::MG_Config {
// Zero/default-initialized at static-init time (all fields have constexpr-friendly
// defaults), so it is safe to read even if MG_ConfigLoader::Init has not run yet.
FeaturesTable Features;
#if MOBILEGL_BUILD_DISAGGREGATED
// Same contract, and for the same reason: MG_Backend::Init() reads Transport, and a
// build order that put it before MG_ConfigLoader::Init() must see Monolith rather than
// a torn enum. Defined only here - in a pull build Config.h makes Transport a constexpr
// and there is nothing to define.
TransportMode Transport = TransportMode::Monolith;
String TransportEndpoint;
IpcTable Ipc;
#endif
} // namespace MobileGL::MG_Config
namespace MobileGL::MG_ConfigLoader {
@@ -252,9 +261,9 @@ namespace MobileGL::MG_ConfigLoader {
// A push build with the knob unset runs every subsystem migrated so far, so the
// shipped path is the one the gates measure; MOBILEGL_PIPE_PUSH=0 in the
// environment is the all-subsystems-pull control that reproduces P1 exactly, and
// kMGPipeSubsystemsMigratedAtP2 (0x7f) is the phase-by-phase control - P3a's two
// subsystems off, everything P2 landed still on.
features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP3a);
// 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.
@@ -297,12 +306,106 @@ namespace MobileGL::MG_ConfigLoader {
#undef ENTRY
}
#if MOBILEGL_BUILD_DISAGGREGATED
// MOBILEGL_TRANSPORT = monolith | inproc | spawn | unix:<path> | pipe:<name>
// (ARCHITECTURE.md:583). Shaped after InitBackendType above: an exact-name table, then
// one fallback that names what it did instead. The two prefixed forms are the only
// reason this is not literally that function's ENTRY macro.
//
// spawn / unix: / pipe: PARSE AND THEN REFUSE. They are P6's, and the refusal is NAMED
// rather than silent, because the failure this avoids is a P6 lane that set
// MOBILEGL_TRANSPORT=spawn, fell back to monolith, and went green on the wrong arm.
// The mode is left at Monolith so nothing half-initializes.
inline void InitTransport() {
String value;
QueryEnvVariable("MOBILEGL_TRANSPORT", value, "monolith");
String lowered = value;
std::transform(lowered.begin(), lowered.end(), lowered.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
MG_Config::TransportEndpoint.clear();
if (lowered.empty() || lowered == "monolith") {
MG_Config::Transport = MG_Config::TransportMode::Monolith;
return;
}
if (lowered == "inproc") {
MG_Config::Transport = MG_Config::TransportMode::InProcess;
MGLOG_I("Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream crosses a real "
"ring to an apply thread");
return;
}
// The three P6 forms. Recognised precisely, so the diagnostic can say "not yet"
// rather than "unknown", which are different bugs on the operator's side.
if (lowered == "spawn" || lowered.compare(0, 5, "unix:") == 0 ||
lowered.compare(0, 5, "pipe:") == 0) {
MGLOG_E("Config: MOBILEGL_TRANSPORT='%s' names a transport P6 implements and P5 does "
"not; staying on monolith. This run is NOT a split run.",
value.c_str());
MG_Config::Transport = MG_Config::TransportMode::Monolith;
return;
}
MGLOG_W("Config: Ignoring invalid env variable MOBILEGL_TRANSPORT='%s'; expected "
"monolith|inproc|spawn|unix:<path>|pipe:<name>, using monolith",
value.c_str());
MG_Config::Transport = MG_Config::TransportMode::Monolith;
}
// The MOBILEGL_IPC_* family (Config.h IpcTable). Parsed unconditionally rather than only
// when Transport != Monolith: a knob that silently means nothing on one arm of an A/B is
// how an A/B stops being one, and the ranges below are the diagnostics.
inline void InitIpc() {
auto& ipc = MG_Config::Ipc;
QueryEnvVariable("MOBILEGL_IPC_SERVER_PATH", ipc.ServerPath, "");
// Both ring floors are 1 MiB, not 0: a ring caps ONE record at half its size, and
// the catalogue's largest fixed payload (MGPFramebufferState, 304 bytes) plus a
// create_shader_state archive already needs far more than a toy ring. The ceilings
// are sanity, not policy.
ipc.RingMb = QueryEnvUint32("MOBILEGL_IPC_RING_MB", 8, 1, 1024);
ipc.StageMb = QueryEnvUint32("MOBILEGL_IPC_STAGE_MB", 32, 1, 4096);
ipc.SpinUs = QueryEnvUint32("MOBILEGL_IPC_SPIN_US", 50, 0, 1000000);
// 0 is admitted ON PURPOSE and is the negative control of exit gate E3(a): it turns
// the persistent-map push OFF, and PersistentCoherentMapScenario must go red.
ipc.PersistentBlockKb = QueryEnvUint32("MOBILEGL_IPC_PERSISTENT_BLOCK_KB", 64, 0, 65536);
// 2 is the only tier P5 implements (R-6). 0 and 1 parse here and are refused at the
// point of use, which is where the "P11" in the message belongs.
ipc.AdoptTier = QueryEnvUint32("MOBILEGL_IPC_ADOPT_TIER", 2, 0, 2);
ipc.VerbBarrier = QueryEnvUint32("MOBILEGL_IPC_VERB_BARRIER", 1, 0, 1);
ipc.StrictErrors = QueryEnvFlag("MOBILEGL_IPC_STRICT_ERRORS");
ipc.Audit = QueryEnvFlag("MOBILEGL_IPC_AUDIT");
QueryEnvVariable("MOBILEGL_IPC_SERVER_AFFINITY", ipc.ServerAffinity, "auto");
if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return;
// One line, on the arm where these numbers decide behaviour, because every one of
// them is a number a bug report has to quote.
MGLOG_I("Config: IPC ring=%uMiB stage=%uMiB spin=%uus persistent-block=%uKiB "
"adopt-tier=%u verb-barrier=%u strict=%d audit=%d affinity='%s'",
ipc.RingMb, ipc.StageMb, ipc.SpinUs, ipc.PersistentBlockKb, ipc.AdoptTier,
ipc.VerbBarrier, static_cast<int>(ipc.StrictErrors), static_cast<int>(ipc.Audit),
ipc.ServerAffinity.c_str());
if (ipc.VerbBarrier == 0) {
MGLOG_W("Config: MOBILEGL_IPC_VERB_BARRIER=0 is the R-1 NEGATIVE CONTROL and is "
"expected to fail: the client still pulls 31 of 63 PipeInputs fields from a "
"live GLContext, so an unbarriered queue lets the server read future values");
}
if (ipc.PersistentBlockKb == 0) {
MGLOG_W("Config: MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0 is the E3(a) NEGATIVE CONTROL: "
"the persistent-map push is OFF and a coherent-map scenario must go red");
}
}
#endif
void Init() {
MGLOG_D("Loading configuration from environment variables...");
InitializeAcceptedEnvVariables();
InitBackendType();
InitFeatures();
#if MOBILEGL_BUILD_DISAGGREGATED
// After InitFeatures, so the one line InitIpc logs is the last word on this run's
// configuration, and before the accepted-env map is destroyed just below.
InitTransport();
InitIpc();
#endif
// Destroy the map since we won't need it anymore
acceptedEnvVariablesMap.reset();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+478
View File
@@ -20,6 +20,11 @@
#if MOBILEGL_PIPE_PUSH
// P3a: the vertex-input payload views the handle arm of the VAO twin consumes.
#include <MG_Pipe/MGPipeTypes.h>
// P4a: the RECORDS the five re-keyed twins read instead of the frontend object. The readers
// below hand back pointers to them, and MGPipeResourceRecord::PendingUpload is a nested type,
// so a forward declaration would not do. Push-only, like everything else P4a adds to this
// header, so the pull build's include graph is unchanged (D-P).
#include <MG_Pipe/PipeApply.h>
#endif
namespace MobileGL::MG_Backend::DirectGLES {
@@ -428,6 +433,52 @@ namespace MobileGL::MG_Backend::DirectGLES {
return nullptr;
}
// P4a (D-B1): resolve-or-create BY THE HANDLE THE CALL CARRIED. This is the shape P3a
// already runs for the buffer family through BackendBufferResourceTable, lifted onto
// the five registries that still mint their own handles off a frontend lifetime id -
// the debt SlotTables.h records against itself at the top of that file.
//
// POINTER, not the reference GetOrCreate(StatePtr) returns, and that is deliberate:
// this call has THREE ways to decline and every one of them has to be visible to the
// caller rather than answered with a parked twin.
// * the legacy arm is running, so there is no slot table to index;
// * the slot is past the table's sanity bound (a corrupt 32-bit slot must not decide
// a vector resize);
// * the generation is BEHIND the live entry's. SlotTables.h:301-321 is the whole
// argument: forward is a recycle and resets the twin, BACKWARD is refused, because
// adopting it would destroy the incumbent LIVE twin's driver ids and then stamp the
// slot back to the dead object's generation - the shape commit d7655247 fixed.
// The refusal is SILENT here and gets its release-build voice at the per-kind resolver
// in Managers.cpp, exactly as GetOrCreateBufferResourceForHandle gives P3a's.
BackendPtr* GetOrCreateByHandle(MG_Pipe::MGPipeHandle handle) {
if (!EsprytSlotTablesEnabled()) return nullptr;
if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr;
if (handle.Slot >= SlotTable::kMaxHandleSlot) return nullptr;
const Uint32 liveGen = m_slotTable.LiveGenAt(handle.Slot);
if (liveGen != 0 && liveGen > handle.Gen) return nullptr;
return &m_slotTable.GetOrCreate(handle);
}
// The generation of the LIVE entry at this slot, or 0. It exists so a caller can
// DIAGNOSE, in a release build where MOBILEGL_ASSERT is inert, the refusal above
// performs silently.
Uint32 LiveGenAt(Uint32 slot) const {
if (!EsprytSlotTablesEnabled()) return 0;
return m_slotTable.LiveGenAt(slot);
}
// NO ReleaseByHandle HERE, AND THAT IS A DECISION (review M-4). The death half of
// GetOrCreateByHandle exists for a kind whose announcement is its own destroy CALL
// rather than the shared death notice - which is the BUFFER family
// (BackendBufferResourceTable::ReleaseByHandle, SlotTables.h, called from
// resource_destroy) and none of the five kinds this registry serves: every one of them
// dies through DestroyByLifetimeId below, because P4a adds no server-side destroy arm
// for a texture, a renderbuffer, a framebuffer, a sampler CSO or a shader CSO. v1
// declared one here anyway and it had no caller on either arm, which made its bound and
// its wording things nobody would exercise until P5. The one-line wrapper comes back in
// the commit that gives it a caller; SlotTable::ReleaseByHandle underneath is untouched
// and is what SanityTest drives directly.
// P2 step e2. STATIC, because a death notice is about an object and not about a
// registry instance: it is answered by EVERY table of this kind that exists - this
// registry's own, and any by-value copy of it a fixture or a context reset is holding
@@ -552,6 +603,121 @@ namespace MobileGL::MG_Backend::DirectGLES {
using TwinRegistry = StateBackendObjectRegistry<StateObject, BackendObject>;
#endif
#if MOBILEGL_PIPE_PUSH
// ---- P4a (D-K3): one arm resolver per family, beside BufferImpl's two ----
//
// Four bits and therefore four resolvers, 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.
//
// THE RESOLUTION IS LAZY, at the first use, and never at bring-up. Backend context creation
// runs inside eglMakeCurrent and the integration harness pre-flights exactly that sequence
// in a FORKED CHILD; a child that dies on a signal is reported as "no usable GPU" and every
// scenario in the lane is SKIPPED - the lane goes green having run nothing, on the very
// pair of env vars the A/B is driven with, which is what ROADMAP.md:7 forbids. So a stop
// has to land in a test body, i.e. at the first lookup. That is what the inline latches
// below give: a guard-variable load and a perfectly-predicted branch per consult, and the
// arm dispatch folds into the caller (SlotTables.h's EsprytSlotTablesEnabled argument
// verbatim - every one of these is consulted on the per-draw path).
//
// ALL FOUR CAN REACH NoArm and all four STOP there rather than skipping green, because
// every one of the four legacy arms is compiled under MOBILEGL_PIPE_LEGACY_MEMOS:
// framebuffer - the four g_fboSynced* arrays and StampSyncedFBO;
// texture - the twin's m_prevTextureInfo / m_syncedContentVersion cheap-gate trio;
// samplers - UnitSamplerLookupMemo's WeakPtr arm and SamplerPassMemo's raw
// BackendSamplerObject* rows;
// programs - g_programTwinLookupMemo.
//
// THREE OF THEM CARRY A DEPENDENCY (MGPipe.h, D-K2) and it is diagnosed and REFUSED here
// rather than half-run, the bit-8-requires-bit-7 shape ResolveVertexInputSubsystemArm
// already ships: bit 11 requires bit 10, bit 9 requires bit 10, bit 10 requires bit 7. The
// mirror pairs (10 without 11, 10 without 9, 7 without 10) are all FINE and are said so out
// loud, because an unreachable branch that says something different is how the reachable
// one drifts. Bit 12 depends on nothing: a ShaderCso handle names no texture and no buffer.
//
// ResolveFramebufferSubsystemArm additionally carries D-C3's bring-up refusal: the wire
// array is MGPFramebufferState::Color[8] and GetDynamicParameters().MaxColorAttachments is
// the driver's RAW ES cap, which is not clamped to 8 on this path. A driver reporting more
// would silently truncate the record, so the bit is refused with one MGLOG_E naming the cap
// and the legacy arm runs. Widening the payload is a wire change nobody has evidence for;
// truncating silently is the bug class this phase is closing.
Bool ResolveFramebufferSubsystemArm();
Bool ResolveTextureResourceSubsystemArm();
Bool ResolveSamplerSubsystemArm();
Bool ResolveProgramSubsystemArm();
inline Bool FramebufferSubsystemEnabled() {
static const Bool enabled = ResolveFramebufferSubsystemArm();
return enabled;
}
inline Bool TextureResourceSubsystemEnabled() {
static const Bool enabled = ResolveTextureResourceSubsystemArm();
return enabled;
}
inline Bool SamplerSubsystemEnabled() {
static const Bool enabled = ResolveSamplerSubsystemArm();
return enabled;
}
inline Bool ProgramSubsystemEnabled() {
static const Bool enabled = ResolveProgramSubsystemArm();
return enabled;
}
// ---- P4a: what the twins read INSTEAD of the frontend object ----
//
// One reader per record kind, all const, all null-on-miss, and all bounds-checked against
// the applier's own dense table rather than against a constant: a slot at or above the
// table's size simply has no record, which is the same answer as "not live" and is not a
// protocol error on THIS side (the applier already refused and counted the call that would
// have created it - PipeApply.h's RefusedObjectCalls).
//
// A NULL ANSWER IS NOT A FALL-BACK TO THE FRONTEND. On a family's handle arm, quietly
// reaching into the frontend object again would hide a missing record behind a picture that
// still looks right, which is exactly what the subsystem A/B exists to expose
// (MarkBufferGpuWritten's note, P3a). Every caller below either declines the work with a
// named MGLOG_E_ONCE or runs its family's LEGACY arm, decided by the family latch and
// never per record.
//
// The returned pointer is into a Vector the applier may grow, so it is valid only until the
// next applier call - the same rule the legacy arm's map-into pointers carried, and every
// caller here reads what it needs and lets go.
const MG_Pipe::MGPipeResourceRecord* PipeTextureRecordForHandle(MG_Pipe::MGPipeHandle res);
const MG_Pipe::MGPipeResourceRecord* PipeRenderbufferRecordForHandle(MG_Pipe::MGPipeHandle res);
const MG_Pipe::MGPipeSamplerCsoRecord* PipeSamplerCsoRecordForHandle(MG_Pipe::MGPipeHandle cso);
const MG_Pipe::MGPipeSamplerViewRecord* PipeSamplerViewRecordForHandle(MG_Pipe::MGPipeHandle view);
// ShaderCso is the one kind whose slot space is split in two on the CLIENT side - the
// composite band lives in its own dense table so a single program-pipeline composite does
// not grow a 983040-entry vector (contract D13). The server never learns a handle is a
// composite: this reader hides the split behind one lookup, exactly as the wire does.
const MG_Pipe::MGPipeShaderCsoRecord* PipeShaderCsoRecordForHandle(MG_Pipe::MGPipeHandle cso);
// The pending-upload entry the applier accumulated for this (uploadTarget, level) of this
// texture record, or null (D-D5). SERVER-SIDE STATE, and that is the whole point: the
// client clears its own dirty flags at EMISSION for the levels the applier accepted, while
// Espryt's upload loop has bail arms - an incomplete texture returns early, a multisample
// target refreshes and skips - that today leave the frontend flag set. A naive move of the
// clear to the client would lose exactly those texels. The set survives any number of
// bails; ConsumePipeTextureUpload below is called ONLY where the level actually uploaded.
//
// `uploadTarget` is static_cast<Uint16>(MobileGL::TextureUploadTarget) - the HALF, not the
// packed field. The stored key is MGPSubData::Target whole (low byte MGPipeResourceTarget,
// high byte TextureUploadTarget, ID-12) and both functions decode it with
// MGPipeSubDataUploadTargetOf; they are the only two places this package compares it.
const MG_Pipe::MGPipeResourceRecord::PendingUpload* FindPipeTextureUpload(
const MG_Pipe::MGPipeResourceRecord& record, Uint16 uploadTarget, Uint16 level);
void ConsumePipeTextureUpload(MG_Pipe::MGPipeHandle res, Uint16 uploadTarget, Uint16 level);
// THE SERVER'S OWN RE-DIRTY, armed in the applier's set instead of in the frontend's model
// (esprytobj review M-1). `packedTarget` is a full MGPSubData::Target built with
// MGPipePackSubDataTarget, because the entry this writes has to be indistinguishable from
// one the client emitted. Whole-level, no regions, merged with any entry already there;
// false (and one named log line) when the applier's pending set is at its bound. The three
// server-side MarkStorageDirty sites are enumerated at the definition.
Bool RearmPipeTextureLevelUpload(MG_Pipe::MGPipeHandle res, Uint16 packedTarget, Uint16 level,
const MG_Pipe::MGPBox& wholeLevel);
#endif
namespace BufferImpl {
const GLenum TempBufferTarget = GL_ARRAY_BUFFER;
@@ -1514,6 +1680,61 @@ namespace MobileGL::MG_Backend::DirectGLES {
// single-level texture with a mipmapping filter), and an incomplete texture samples
// (0, 0, 0, 1) rather than its contents.
Bool m_forceSamplerResync = false;
#if MOBILEGL_PIPE_PUSH
// ---- P4a's handle arm: the two prologues that decide WHETHER there is work and
// WHERE the values come from. Both answer null for "nothing to do", which covers
// three cases the caller treats identically and the callee names individually in
// the log: the record's serial has not moved, this texture has no record at all,
// or its params name a sampler CSO the applier does not hold.
//
// NEITHER FALLS BACK TO THE FRONTEND. On this arm the texture family is switched
// over, and quietly re-reading the object would hide a missing record behind a
// picture that still looks right - which is precisely what the subsystem A/B exists
// to expose (MarkBufferGpuWritten's note, P3a).
const SamplerParameters* ResolvePushedBuiltinSampler(
const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject);
// Hands back the whole RECORD rather than its Params, because the parameter push
// reads two things from beside them: Desc.InternalFormat, which decides the two
// channel-widening swizzle compositions, and Params.BuiltinSampler, which is where
// the border colour lives (it is sampler state, GL 4.6 table 23.18, and P4a does
// not duplicate it onto MGPTextureParams).
const MG_Pipe::MGPipeResourceRecord* ResolvePushedTextureParams(
const SharedPtr<MG_State::GLState::ITextureObject>& stateTextureObject);
// ---- P4a's handle arm (D-B3). Three server-owned serials that REPLACE, on their
// own arm, the six frontend-version memos above; the legacy members stay beside
// them under MOBILEGL_PIPE_LEGACY_MEMOS because ARCHITECTURE.md:369 keeps the
// pre-handle arm compiled through P3a/P4a, and because clearing the family's bit
// has to run the pre-handle arm rather than a half-migrated one.
//
// Inside the guard, so the PULL build's BackendTextureObject is byte-for-byte the
// pre-P4a object and G1's admitted-resize set stays empty (D-P).
//
// 0 is never a real serial - the applier's counters start at 1 and only ever
// advance, including across a make-current (PipeApply.cpp's three-way argument) -
// so a zeroed memo is a guaranteed miss and a fresh twin owes a full sync.
// The resource record's Serial at the last completed mipmap sync. It replaces the
// whole cheap-gate trio (m_syncedShapeContextId / m_syncedShapeGeneration /
// m_syncedShapeParamsVersion) AND m_syncedContentVersion: the applier bumps it on
// every respecify and every sub-data it applies to this resource, which is exactly
// the union those four covered, without the coarse "any texture's churn re-opens
// every gate" behaviour the sampling-resolution generation had.
Uint64 m_syncedResourceSerial = 0;
// The record's ParamsSerial at the last SyncTextureParamsToBackend. Replaces
// m_syncedTextureParamsVersion; MGPTextureParams::ForceResync replaces
// m_forceTextureParamsResync and is consumed the same way - read, acted on, and
// NOT written back, because the client never clears a server flag and the server
// never clears the client's (D-E2, the D-D5 inversion applied to two bits).
Uint64 m_syncedParamsSerial = 0;
// The BuiltinSampler CSO record's Serial at the last SyncBuiltinSamplerToBackend,
// plus the handle it was read through - a texture whose params name a DIFFERENT
// CSO than last time has had its sampling state replaced wholesale even if the new
// CSO's serial happens to match, which is a real sequence under content addressing
// (two textures sharing one CSO, then one of them diverging).
Uint64 m_syncedBuiltinSamplerSerial = 0;
MG_Pipe::MGPipeHandle m_syncedBuiltinSampler = MG_Pipe::kMGPipeNullHandle;
#endif
};
void ActivateTextureUnit(Uint unit);
@@ -1606,11 +1827,64 @@ namespace MobileGL::MG_Backend::DirectGLES {
// mismatch means some backend texture id was re-minted since, and any of this
// twin's attachment points may still hold the dead id even though the frontend
// attachment versions match - so the walk re-attaches everything first.
//
// SERVER-OWNED AND IT SURVIVES P4a (D-B3). It answers "did *I* re-mint a driver
// texture id", which no client-side version can answer; dropping it would
// reintroduce exactly the class of bug commit d7655247 fixed on the buffer side.
Uint64 m_syncedBackendIdGeneration = 0;
#if MOBILEGL_PIPE_PUSH
// P4a (D-C4): MGPFramebufferState::ContentHash as of this twin's last sync, PER
// TARGET IT WAS SYNCED AS, and it is the second of the hash's two jobs - "the server's
// render-pass memo key, and the CLIENT's emission suppressor". It replaces
// m_syncedFrontendAttachmentVersions AS A KEY (the array stays: it is what the
// legacy arm compares, and it is the mechanism the handle arm re-arms through).
//
// The hash covers every field the record carries - Fbo included, so a recycled
// framebuffer handle whose successor happens to carry an identical attachment set
// can never be suppressed against its predecessor, and DrawBuffers[8] included, so
// a suppressed record provably means the draw-buffer array did not move, which
// provably means the fragColor broadcast count did not move.
//
// PER TARGET rather than one, and it stays that way under ID-19's per-OBJECT record:
// there is now ONE record for this framebuffer, but syncing it as Draw and syncing it
// as Read do different work (glDrawBuffers and the four cross-object masks are
// Draw-only, glReadBuffer is Read-only), so "I have already applied this record" is a
// per-target claim and one memo would let the second target skip work the first never
// did. 0 is never a live hash (a computed 0 is remapped to 1 by the client's
// suppressor), so a zeroed memo is a guaranteed miss.
Array<Uint64, SizeT(FramebufferTarget::FramebufferTargetCount)> m_syncedRecordHashes = {0};
#endif
};
extern TwinRegistry<MG_State::GLState::FramebufferObject, BackendFramebufferObject, MG_Pipe::MGPipeKind::Framebuffer>
g_backendFramebufferObjects;
#if MOBILEGL_PIPE_PUSH
// P4a (D-C2 as corrected by ID-19): the applier's record for THE FRAMEBUFFER OBJECT this
// handle names, or null.
//
// v1 asked the applier for its two BOUND-target working records and answered null unless
// one of them happened to name this twin - which meant every DSA entry point
// (BlitNamedFramebuffer, the four ClearNamedFramebuffer*) drove a framebuffer that is
// bound to neither target, found no record, declined, and then had the clear or blit
// issued against a driver FBO that never got its attachments. The record is now keyed by
// the framebuffer HANDLE (MGPipeApplierState::FramebufferRecords, wire v3), so a record
// that comes back is this framebuffer's by construction and it comes back whether the
// object is bound to Draw, to Read, to both or to neither. A null here means "no emission
// has ever described this framebuffer, or the handle's generation is stale" - both of
// them seam defects on an integrated tree, never a binding question.
//
// `fbo` is the handle the caller resolved for this twin; passing it in rather than
// resolving it here keeps the monolith-glue lookup at one site per sync.
const MG_Pipe::MGPFramebufferState* PushedFramebufferRecord(MG_Pipe::MGPipeHandle fbo);
// THE BINDING QUESTION, WHICH IS NOW A DIFFERENT QUESTION FROM THE DESCRIPTION (ID-19(d)):
// "is the framebuffer this handle names the one bound to `target`". One array compare
// against MGPipeApplierState::BoundFramebuffer, never a record lookup - a Named record
// describes an object without claiming any binding for it, so asking the record would
// give the wrong answer by construction.
Bool PushedFramebufferIsBoundTo(FramebufferTarget target, MG_Pipe::MGPipeHandle fbo);
#endif
// True when the read buffer names a fixed-point (norm/snorm) attachment that the
// backend actually stores in a floating-point format. GL clamps a read from a
// fixed-point colour buffer to [0,1] (GL_CLAMP_READ_COLOR defaults to
@@ -1704,6 +1978,16 @@ namespace MobileGL::MG_Backend::DirectGLES {
// the driver FBO would keep the deleted texture name attached forever. The
// SyncCurrentFBO gate compares this generation (below) to re-enter the sync,
// and each twin re-arms its per-attachment memo on a mismatch (SyncToBackend).
//
// AND WHENEVER AN ATTACHABLE OBJECT'S DRIVER STORAGE IS REDEFINED IN PLACE (P4a fable
// seam F-3): a mutable texture regenerated on the same id, a renderbuffer re-storaged
// on the same id. The id did not move, but the four cross-object masks SyncToBackend
// computes from the attachment's format did, and nothing else the FBO memo reads sees
// a respecify of an attached object. So "did I change something under an attachment
// point that no frontend version can tell the framebuffer about" is what this counts,
// and the re-mint is one case of it. The in-place bumps are compiled under
// MOBILEGL_PIPE_PUSH: G1 keeps the pull library byte-identical to the P4a baseline,
// so the pull build keeps the pre-P4a hole until they land on dev on their own.
extern Uint64 g_attachmentBackendIdGeneration;
// What g_attachmentBackendIdGeneration was when SyncCurrentFBO last stamped each
// target; part of the synced tuple above.
@@ -1718,6 +2002,27 @@ namespace MobileGL::MG_Backend::DirectGLES {
// GL_FRAMEBUFFER binds both targets.
void BindFramebufferId(GLenum fbTarget, Uint id);
Uint CurrentFramebufferBinding(FramebufferTarget target);
#if MOBILEGL_PIPE_PUSH
// THE HANDLE ARM'S OWN FRAMEBUFFER MEMOS, AND THEY ARE PACKAGE E's STORAGE
// (DirectGLES.cpp: g_fboSyncedSerials, g_fboRecordsTrusted). E's review MAJOR-4 handed
// this to D because InvalidateFramebufferBindingCache is in THIS file and has three
// callers E cannot reach - MG_Test/SanityTest.cpp's ScopedStateGuardMocks::ResetShadows
// and ScopedBackendTwinMocks' constructor and destructor - which clear the pre-handle
// trio and would leave the handle-arm memos claiming a target is synced across a GLES
// function-table swap. Calling it from INSIDE InvalidateFramebufferBindingCache is what
// makes forgetting impossible, and that call is written below.
//
// IT IS GATED, AND HERE IS THE HANDSHAKE, because the definition is `static` in E's file
// on the tree this package was built against (esprytdraw v2, DirectGLES.cpp:2789) and an
// internal-linkage function cannot be called from Managers.cpp. E's verification round
// drops that one keyword; D's verification round flips this constant to 1, in this file,
// one line. Neither side can do it silently: the flip has no other reader and the
// declaration below has no other definition.
#define MOBILEGL_ESPRYT_FBO_HANDLE_ARM_MEMOS_LINKED 0
#if MOBILEGL_ESPRYT_FBO_HANDLE_ARM_MEMOS_LINKED
void InvalidateFramebufferHandleArmMemos();
#endif
#endif
void InvalidateFramebufferBindingCache();
// A driver framebuffer id is about to be deleted: ES reverts every target that
// currently binds it to 0, so the binding shadow has to follow or the next
@@ -2009,6 +2314,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
// stale as one built before a relink - while the sampler half, which really is
// re-issued per draw, needs nothing of the sort.
Uint32 GetSyncedImageUnitVersion() const { return m_syncedImageUnitVersion; }
#if MOBILEGL_PIPE_PUSH
// P4a (D-B3, D-H5): the ShaderCso record's Serial this backend program was built
// from. It is what the draw path's nine-clause rebuild condition reads on the handle
// arm INSTEAD OF the two frontend versions above - one server-owned counter that
// moves on every create_shader_state the applier applies to this handle, including a
// RE-create on the same handle, which is how a relink travels (Gen moves only on slot
// reuse, never on a respecify).
//
// THE CLAUSE COUNT DOES NOT SHRINK, and a brief that treated create_shader_state as
// self-contained would produce a per-draw rebuild: the other eight inputs - the draw
// FBO's snorm/unorm 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 still specialised at the verb, from state this backend holds.
// 0 means "never stamped", which is a guaranteed miss (applier serials start at 1).
Uint64 GetSyncedShaderCsoSerial() const { return m_syncedShaderCsoSerial; }
#endif
// Whether the (unit, bound format) pairs this program's FORMAT-LESS image uniforms
// resolve to are still the ones its ESSL was generated against.
//
@@ -2108,6 +2429,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
BufferImpl::UboRingAllocation m_globalUboRingAllocation;
Uint32 m_syncedLinkVersion = ~0u;
Uint32 m_syncedImageUnitVersion = ~0u;
#if MOBILEGL_PIPE_PUSH
// P4a's replacement for the two above on the handle arm; see GetSyncedShaderCsoSerial.
// Push-only, so the pull build's object is byte-for-byte the pre-P4a one (D-P).
Uint64 m_syncedShaderCsoSerial = 0;
#endif
// Image units addressed by the program's FORMAT-LESS image uniforms, and the digest
// of the (unit, format) pairs the generated ESSL baked. Empty/0 for every program
// that declares a format on all of its images, which is the overwhelming majority -
@@ -2211,7 +2537,32 @@ namespace MobileGL::MG_Backend::DirectGLES {
~BackendSamplerObject();
BackendSamplerObject(const BackendSamplerObject&) = delete;
BackendSamplerObject& operator=(const BackendSamplerObject&) = delete;
#if MOBILEGL_PIPE_PUSH
// THE SAMPLER CSO HANDLE IS CARRIED BY THE CALLER, and it has to be, because a
// SamplerCso is CONTENT-ADDRESSED on the client (D-F1) while this twin is keyed on
// the frontend OBJECT. g_backendSamplerObjects mints a SamplerCso slot off the
// SamplerObject's lifetime id - that handle is this twin's identity and is what
// FindByHandle memos index - but the client's cache allocates its handles by
// CONTENT (MGPipeSlots().Allocate, SamplerEmit.h), so no create_sampler_state ever
// lands at the identity handle and looking a record up by it can only ever miss.
// The carried fact that DOES name the right record is the applier's own
// MGPipeApplier().BoundSamplerStates[unit], which the client writes per unit at
// bind_sampler_states; the caller that knows the unit passes it here.
//
// Defaulted so a caller that has no unit - the backend's OWN raw-depth-fetch
// sampler (DirectGLES.cpp:217), a SamplerObject the client has never seen and for
// which no record can exist - keeps working: that arm reads the object, which is
// the authority for server-owned state. An APPLICATION sampler reaching here
// without a handle is the E-side call-site gap and says so once.
//
// Push-only spelling on purpose: a defaulted parameter is still part of the
// signature, so widening it unconditionally would rename this symbol in the PULL
// build and P4a's admitted-change set is EMPTY (D-P/G1).
void SyncToBackend(const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject,
MG_Pipe::MGPipeHandle pushedCso = MG_Pipe::kMGPipeNullHandle);
#else
void SyncToBackend(const SharedPtr<MG_State::GLState::SamplerObject>& stateSamplerObject);
#endif
void Bind(Uint unit);
Uint GetBackendSamplerId() const;
@@ -2221,6 +2572,19 @@ namespace MobileGL::MG_Backend::DirectGLES {
Bool m_isInitialized = false;
SamplerParameters m_cacheSamplerParameters;
Uint16 m_syncedSamplerVersion = 0;
#if MOBILEGL_PIPE_PUSH
// P4a (D-B3): the SamplerCso record's Serial at the last completed sync. It replaces
// m_syncedSamplerVersion, which stays beside it because the pre-handle arm compiles
// under MOBILEGL_PIPE_LEGACY_MEMOS through P3a/P4a (ARCHITECTURE.md:369).
//
// The two are not interchangeable and that is the point: the frontend version is per
// OBJECT, while the serial is per CONTENT-ADDRESSED CSO, and two frontend samplers
// with identical parameters share one CSO and therefore one serial - so under the
// handle arm the second of them costs no driver call at all.
//
// Push-only, so the pull build's object is byte-for-byte the pre-P4a one (D-P).
Uint64 m_syncedSamplerSerial = 0;
#endif
};
void UnbindSampler(Uint unit);
@@ -2229,8 +2593,108 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_boundSamplersCache;
extern TwinRegistry<MG_State::GLState::SamplerObject, BackendSamplerObject, MG_Pipe::MGPipeKind::SamplerCso>
g_backendSamplerObjects;
#if MOBILEGL_PIPE_PUSH
// P4a FABLE SEAM F-4: THE TWIN FOR A CONTENT-ADDRESSED SamplerCso HANDLE.
//
// bind_sampler_states carries, per unit, the handle of a CSO the client allocated BY
// CONTENT (SamplerEmit.h: MGPipeSlots().Allocate with no lifetime id), while every twin
// in g_backendSamplerObjects was minted off a SamplerObject's lifetime id - two disjoint
// slot families out of one allocator. So `g_backendSamplerObjects.FindByHandle(
// BoundSamplerStates[unit])` (the record arm of BindCurrentUnitSamplers, E's S4) could
// never find a twin, the record arm bound nothing on every draw, and every glBindSampler
// reached the driver only through the pre-handle program pass - S-1's confusion one
// loop over, and exactly what SamplerEmit.h:201-205 forbids ("a backend must NOT key a
// sampler twin on a SamplerObject's lifetime id; the twin's life is
// create_sampler_state -> delete_sampler_state").
//
// This is the twin keyed the way the record is: resolved-or-created AT THE CSO HANDLE
// (GetOrCreateByHandle, the same slot table, a slot the identity family can never hold)
// and synced from the record it names, serial-gated. Two callers bind it - the record
// arm of BindCurrentUnitSamplers and the program pass's sampler override - so the two
// cannot ping-pong the unit between an identity twin and a CSO twin. Its death is the
// slot's recycle: the client's LRU eviction drops the record and frees the slot, and
// the next handout at that slot arrives with a moved generation, which GetOrCreate(
// handle) answers by resetting the twin (the driver sampler goes with it). A twin for
// an evicted CSO therefore lives until its slot is reused - bounded by the cache's
// capacity, never by draw count - and there is no delete_sampler_state hook to retire it
// earlier; the ops table carries none for this kind.
//
// Null, loudly, when the handle names no record (a client seam) or cannot be adopted
// (a generation behind the slot's live entry); null silently for the null handle. The
// pre-handle arm - a twin keyed on the frontend object - is untouched and still serves
// the raw-depth-fetch sampler and every caller that carries no handle.
BackendSamplerObject* ResolveSamplerCsoTwin(MG_Pipe::MGPipeHandle cso);
#endif
} // namespace SamplerImpl
#if MOBILEGL_PIPE_PUSH
namespace SamplerViewImpl {
// P4a (D-F2/D-F3): the SIXTH Espryt twin table, and the only one of the six whose kind
// has no frontend object at all. MobileGL has no sampler-view class: GL binds a texture
// to a unit and the sampler uniform's type, the mipmap-completeness predicates and
// IsUndefinedDefaultTexture decide what the shader sees. Gallium's one-view-per-slot IS
// that resolved form, the resolution moves to the CLIENT (ARCHITECTURE.md:206), and
// create_sampler_view carries the restrictions the resolution had to read.
//
// So this twin owns NO DRIVER ID. There is nothing in ES to create for a view; the id
// the unit binds is the texture's, and it lives on BackendTextureObject. What this twin
// is, is the server's MEMO of one resolved view: the record it was built from, keyed on
// that record's serial, plus the two BACKEND-SPECIFIC POST-PROCESSINGS
// ARCHITECTURE.md:206 keeps on the server and which act on the already-resolved set.
// Espryt's is the raw-depth-fetch sampler substitution; Magma's feedback-loop detection
// is its own and is not here.
//
// A twin with no driver id still earns a table: it is what turns "re-derive the
// substitution decision for every sampled unit of every draw" into one serial compare,
// and it is the slot space the client's per-texture SamplerViewCso handle indexes.
// There is deliberately no destructor: nothing here owns a GPU object, so the teardown
// sentinel's whole reason (a twin destructor must not call into an unloaded driver)
// does not apply and the default one is correct in every teardown order.
struct BackendSamplerViewObject {
// The view record as last synced, verbatim. Reading it here rather than re-asking
// the applier is what lets a caller hold the twin across another applier call.
MG_Pipe::MGPSamplerView View{};
// The applier record's Serial this memo was built from. 0 = never synced, and 0 is
// never a real serial (the applier's counters start at 1), so a zeroed memo is a
// guaranteed miss.
Uint64 SyncedSerial = 0;
// Espryt's post-processing, decided from the RESOLVED set: the view's
// InternalFormat answers IsDepthFormatInternalFormat and the sampler CSO record's
// SamplerParameters answer compareMode / minFilter / mipmapMode / magFilter. The
// decision is re-derived when either serial moves; the sampler serial is kept
// beside it so a sampler mutation alone re-derives it.
Uint64 SyncedSamplerSerial = 0;
Bool NeedsRawDepthFetchSampler = false;
};
// Handle-keyed ONLY, exactly like P3a's BackendBufferResourceTable: the StateObject
// parameter names ITextureObject because the template names one and because the view is
// minted off the TEXTURE's lifetime id (D-F2: one view per ITextureObject), which is
// what makes HandleOf below resolve at all. Not one member that would dereference it is
// instantiated - no Find(StateObject*), no ForEachLive - and the handle overloads never
// look at it.
using BackendSamplerViewTable = BackendSlotTable<MG_State::GLState::ITextureObject,
BackendSamplerViewObject,
MG_Pipe::MGPipeKind::SamplerViewCso>;
extern BackendSamplerViewTable g_backendSamplerViews;
// Resolve-or-create / resolve-only by the handle the call carried. Neither touches
// MGPipeSlots(): the handle ARRIVED, already minted by the side that owns minting.
BackendSamplerViewObject* GetOrCreateSamplerViewForHandle(MG_Pipe::MGPipeHandle view);
BackendSamplerViewObject* FindSamplerViewForHandle(MG_Pipe::MGPipeHandle view);
// MONOLITH GLUE, and named as such, the HandleOfBuffer shape: the SamplerViewCso handle
// of a texture this backend is looking at through a frontend object. Legal only because
// the view is minted off the texture's own lifetime id; under a real split neither the
// object nor its lifetime id exists on this side and the handle has to arrive in the
// payload (which, for every path P4a switches over, it does - this is for the paths
// P3b/P4b still owns).
MG_Pipe::MGPipeHandle HandleOfSamplerViewForTexture(
const MG_State::GLState::ITextureObject* textureObject);
} // namespace SamplerViewImpl
#endif
namespace RenderbufferImpl {
class BackendRenderbufferObject {
public:
@@ -2252,6 +2716,20 @@ namespace MobileGL::MG_Backend::DirectGLES {
Int m_cacheWidth = 0;
Int m_cacheHeight = 0;
Int m_cacheSamples = 0;
#if MOBILEGL_PIPE_PUSH
// P4a (D-D2/D-B3): the resource record's Serial at the last completed allocation.
// It replaces the four-field cache above AS A GATE - the four members stay, because
// they are also what the legacy arm compares and what the twin reports about the
// storage it actually holds - and it closes the publication hole D-D2 names:
// RenderbufferObject::{SetInternalFormat, AllocateStorage, SetSamples} bump no
// version and raise no notice, so `glBindRenderbuffer; glRenderbufferStorage(new)`
// on an ALREADY-ATTACHED renderbuffer moved nothing the framebuffer bit could see.
// The client now emits resource_respecify straight from those three mutators, the
// applier bumps this serial, and one compare here sees it.
//
// Push-only, so the pull build's object is byte-for-byte the pre-P4a one (D-P).
Uint64 m_syncedResourceSerial = 0;
#endif
};
extern TwinRegistry<MG_State::GLState::RenderbufferObject, BackendRenderbufferObject, MG_Pipe::MGPipeKind::Renderbuffer>
@@ -194,10 +194,115 @@ namespace MobileGL::MG_Backend::DirectVulkan {
//
// 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.
+16 -1
View File
@@ -688,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
@@ -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
+5 -2
View File
@@ -195,8 +195,11 @@ namespace MobileGL::MG_Pipe {
// when the pipeline version moved, and it keeps the eviction order in the same array as
// the content - a map would need a second structure to answer "which is oldest".
inline MGPipeCsoCache& MGPipeCsoCacheInstance() {
static MGPipeCsoCache cache;
return cache;
// 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
+27
View File
@@ -43,6 +43,33 @@ 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.
//
+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
+20 -34
View File
@@ -57,33 +57,16 @@ namespace MobileGL::MG_Pipe {
// D-A3: BindMask
// ---------------------------------------------------------------------------------
// MGPResourceDesc::BindMask's twelve bits, in the order MGPipeTypes.h names them:
// VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET|
// DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY.
// 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.
//
// They are spelled HERE rather than in MGPipeTypes.h because that header is the contract
// package's and the mask has, so far, exactly one producer: this file. The integrator
// moves them beside the field when a second producer appears (P4a's texture family).
enum MGPipeBindBit : Uint16 {
kMGPipeBindNone = 0,
kMGPipeBindVertex = 1u << 0,
kMGPipeBindIndex = 1u << 1,
kMGPipeBindConstant = 1u << 2,
kMGPipeBindShaderBuffer = 1u << 3,
kMGPipeBindIndirect = 1u << 4,
kMGPipeBindSampler = 1u << 5,
kMGPipeBindShaderImage = 1u << 6,
kMGPipeBindRenderTarget = 1u << 7,
kMGPipeBindDepthStencil = 1u << 8,
kMGPipeBindStreamOutput = 1u << 9,
kMGPipeBindAtomic = 1u << 10,
// THE D-B7 SWITCH. With kCapNeedsHostIndexBytes set the server mirrors this
// resource's bytes so it can rewrite restart indices and flatten multi-draws
// (ARCHITECTURE.md 10.3). Getting it wrong is invisible in monolith and silently
// disables both under split, which is why it is set from the same table as every
// other bit rather than from a special case at the emission site.
kMGPipeBindElementArray = 1u << 11,
};
// 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
@@ -152,12 +135,12 @@ namespace MobileGL::MG_Pipe {
// The discriminators MGPResourceDesc / MGPSubData carry for a BUFFER
// ---------------------------------------------------------------------------------
//
// MGPipeTypes.h documents Target as "Buffer | Tex1D..TexCubeArray | Renderbuffer |
// TexBuffer" and StorageKind as "== TextureStorageType", but P3a is buffer-only and the
// contract package minted no enum for the first list. Buffer is its leading member and
// is therefore 0, which is also what a zero-initialised record already says; the second
// is the frontend enum, named rather than open-coded.
inline constexpr Uint16 kMGPipeResourceTargetBuffer = 0;
// 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);
@@ -552,8 +535,11 @@ namespace MobileGL::MG_Pipe {
// The monolith's one resource tracker, beside the state tracker, the CSO cache and the
// set-hash suppressor.
inline MGPipeResourceTracker& MGPipeResourceTrackerInstance() {
static MGPipeResourceTracker tracker;
return tracker;
// 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;
}
// ---------------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
+25 -7
View File
@@ -18,7 +18,9 @@
// 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.
// 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
@@ -40,15 +42,28 @@
namespace MobileGL::MG_Pipe {
// One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list).
// 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
SetSamplerViews, // P3b
BindSamplerStates, // P3b
SetShaderImages, // P4b
// 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,
};
@@ -86,8 +101,11 @@ namespace MobileGL::MG_Pipe {
// The monolith's one suppressor, beside the tracker and the CSO cache.
inline MGPipeSetHashSuppressor& MGPipeSetHashSuppressorInstance() {
static MGPipeSetHashSuppressor suppressor;
return suppressor;
// 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
+130 -24
View File
@@ -35,6 +35,22 @@ namespace MobileGL::MG_Pipe {
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()) {
@@ -94,14 +110,69 @@ namespace MobileGL::MG_Pipe {
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 Uint32 slot = it->second;
if (slot >= state.Slots.size() || !state.Slots[slot].Live) return kMGPipeNullHandle;
return MGPipeHandle{slot, state.Slots[slot].Gen};
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) {
@@ -112,63 +183,98 @@ namespace MobileGL::MG_Pipe {
void MGPipeSlotAllocator::Free(MGPipeKind kind, MGPipeHandle handle) {
KindState& state = StateOf(kind);
if (handle.Slot >= state.Slots.size()) return;
SlotState& entry = state.Slots[handle.Slot];
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.
if (!entry.Live || entry.Gen != handle.Gen) return;
if (entry.LifetimeId != 0) {
const auto it = state.ByLifetimeId.find(entry.LifetimeId);
// 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;
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 KindState& state = StateOf(kind);
if (handle.Slot >= state.Slots.size()) return false;
const SlotState& entry = state.Slots[handle.Slot];
return entry.Live && entry.Gen == handle.Gen;
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 KindState& state = StateOf(kind);
if (slot >= state.Slots.size()) return 0;
return state.Slots[slot].Gen;
const SlotState* entry = EntryOf(StateOf(kind), kind, slot);
return entry != nullptr ? entry->Gen : 0;
}
Uint64 MGPipeSlotAllocator::LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const {
const KindState& state = StateOf(kind);
if (slot >= state.Slots.size()) return 0;
return state.Slots[slot].LifetimeId;
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 {
return static_cast<Uint32>(StateOf(kind).FreeList.size());
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() {
static MGPipeSlotAllocator allocator;
return allocator;
// 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
+68 -2
View File
@@ -47,6 +47,25 @@ namespace MobileGL::MG_Pipe {
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.
@@ -64,11 +83,40 @@ namespace MobileGL::MG_Pipe {
// otherwise, live or not.
Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const;
Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const;
// One past the highest slot ever handed out of this kind, i.e. what a server-side
// slot-indexed table must be sized to.
// 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();
@@ -85,12 +133,30 @@ namespace MobileGL::MG_Pipe {
// 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{};
};
File diff suppressed because it is too large Load Diff
+361 -25
View File
@@ -20,9 +20,24 @@
//
// WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2,
// computed by comparing a shutter against what the tracker last pushed. P2 emitted for bits
// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and the
// rest are still computed, latched and counted so the per-bit fire rate is a measurement
// rather than a plan, with their fields going through the residual fill until P3b/P4a/P4b.
// 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
@@ -61,22 +76,24 @@ namespace MobileGL::MG_Pipe {
NewPixelPack, // PixelStoreParameters (pack) -> set_pixel_pack_state
NewPatchState, // the patch trio, NaN legal -> set_patch_state
NewVertexAttribDefaults, // glVertexAttrib* defaults -> set_vertex_attrib_defaults
// ---- value class: NEW_VERTEX_ELEMENTS is emitted from P3a; the other three are
// still computed and counted, and are emitted from P3b/P4a on ----
// ---- 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
NewShaderBindings, // image units, block bindings, uniform write set
NewGlobalConstants, // the default-uniform-block image
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 rest
// are still computed and counted only. ----
// 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,
NewSamplerViews,
NewSamplers,
NewShaderImages,
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,
@@ -103,6 +120,22 @@ namespace MobileGL::MG_Pipe {
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",
@@ -146,8 +179,33 @@ namespace MobileGL::MG_Pipe {
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 P3b/P4a/P4b, so there is no
// 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;
}
@@ -243,11 +301,66 @@ namespace MobileGL::MG_Pipe {
// 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(
@@ -256,12 +369,156 @@ namespace MobileGL::MG_Pipe {
program->GetBlockBindingVersion()),
program->GetUniformWriteSetVersion());
constants = MGPipeMixShutter(program->GetLifetimeId(), program->GetUBOContentVersion());
programImages = program->GetImageUnitVersion();
// 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();
@@ -300,16 +557,82 @@ namespace MobileGL::MG_Pipe {
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()));
now[Index(MGPipeDirty::NewSamplerViews)] =
MGPipeMixShutter(textureContent, ctx.GetTextureBindGeneration());
now[Index(MGPipeDirty::NewSamplers)] =
MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration());
now[Index(MGPipeDirty::NewShaderImages)] =
MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages);
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)] =
@@ -380,6 +703,7 @@ namespace MobileGL::MG_Pipe {
m_renderStateVersion.Reset();
m_pipelineStateVersion.Reset();
m_framebufferBind.Reset();
m_readFramebufferBind.Reset();
m_indexSlotVersion.Reset();
m_pack = PixelStoreParameters{};
m_patch = PatchTrio{};
@@ -469,6 +793,12 @@ namespace MobileGL::MG_Pipe {
// 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
@@ -521,8 +851,14 @@ namespace MobileGL::MG_Pipe {
// The monolith's one tracker. Under split there is one per client context; the context
// identity check inside Update is what makes the single instance safe today.
inline MGPipeTracker& MGPipeTrackerInstance() {
static MGPipeTracker tracker;
return tracker;
// 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
+8 -2
View File
@@ -431,8 +431,14 @@ namespace MobileGL::MG_Pipe {
// The monolith's one vertex-input emitter, beside the tracker, the CSO cache, the
// set-hash suppressor and the resource tracker.
inline MGPipeVertexInputEmitter& MGPipeVertexInputEmitterInstance() {
static MGPipeVertexInputEmitter emitter;
return emitter;
// 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
+399 -29
View File
@@ -53,6 +53,9 @@ add_executable(MobileGLIntegrationTest
Harness/HeadlessGL.cpp
Harness/BackendCapsPeek.cpp
Harness/PipeSlotPeek.cpp
Harness/PipeApplyPeek.cpp
Harness/P4aSeamPeek.cpp
Harness/P4aFinalFixPeek.cpp
Scenarios/OrientationScenario.cpp
Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.cpp
@@ -133,6 +136,11 @@ add_executable(MobileGLIntegrationTest
Scenarios/HandleRecycleScenario.cpp
Scenarios/CsoContentAddressingScenario.cpp
Scenarios/ResourceSubsystemControlScenario.cpp
Scenarios/TextureParamsWithoutASamplerViewScenario.cpp
Scenarios/TextureUploadShapeScenario.cpp
Scenarios/ObjectSubsystemControlScenario.cpp
Scenarios/P4aSeamAuditScenario.cpp
Scenarios/P4aFinalFixScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -405,6 +413,60 @@ function(mgl_itest_probe_for_symbol outVar directory symbolRegex)
set(${outVar} "${mglItestProbeHit}" PARENT_SCOPE)
endfunction()
# The same probe, over a CONJUNCTION: BOTH regexes matched ANYWHERE UNDER the directory, not
# necessarily in the same file. outVar is set to "<file matching A> + <file matching B>" when both
# were found and to the empty string otherwise.
#
# P4a needs it for exactly one question and the question cannot be asked any other way. "Does
# MOBILEGL_PIPE_HANDLE_ABA_CONTROL defeat the identity of P4a's OBJECT kinds on this backend?" is
# not answered by "some source reads Features.PipeHandleAbaControl" - MagmaPipeArms.h does, and its
# consumers are Magma's VERTEX-INPUT keys, so a single-regex probe would arm the six P4a ABA
# controls on a backend where the knob cannot reach a texture, a framebuffer, a sampler, a view or a
# program, and every one of them would assert a corruption nothing on the tree can produce - a hard
# red on an always-on integration-gpu lane. Nor is it answered by "some source names a P4a subsystem
# bit", which will become true for a backend that honours the mask long before anyone wires the
# knob. What the arm needs is BOTH FACTS TO BE TRUE OF THE BACKEND.
#
# DIRECTORY-WIDE RATHER THAN PER FILE, and that is review finding F-M5 rather than a preference.
# Requiring one file to carry both makes the arming depend on the FILE LAYOUT a later package
# chooses: a backend that wires the knob in Managers.cpp while its P4a subsystem constants live in
# SlotTables.h satisfies the question and fails the probe, the six controls keep printing wired=0
# and asserting the correct pixels, and NOTHING fails, warns or records that the expected flip did
# not happen - the one failure mode a control whose flip is in the future has. The false-positive
# this trades against is a backend that reads the knob somewhere and names a P4a bit somewhere else
# without connecting them; that costs a red lane an engineer must look at, which is the direction
# that gets noticed. Both spellings of the answer are printed, so the configure log says which file
# supplied which half.
#
# Same staleness guarantees as the single-regex probe: CONFIGURE_DEPENDS on the glob, and every file
# it finds appended to CMAKE_CONFIGURE_DEPENDS.
function(mgl_itest_probe_for_two_symbols outVar directory symbolRegexA symbolRegexB)
file(GLOB_RECURSE mglItestProbeSources CONFIGURE_DEPENDS
"${directory}/*.h" "${directory}/*.hpp" "${directory}/*.cpp" "${directory}/*.c")
set(mglItestProbeHitA "")
set(mglItestProbeHitB "")
foreach(mglItestProbeSource IN LISTS mglItestProbeSources)
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mglItestProbeSource}")
if (NOT mglItestProbeHitA)
file(STRINGS "${mglItestProbeSource}" mglItestProbeLinesA REGEX "${symbolRegexA}")
if (mglItestProbeLinesA)
set(mglItestProbeHitA "${mglItestProbeSource}")
endif()
endif()
if (NOT mglItestProbeHitB)
file(STRINGS "${mglItestProbeSource}" mglItestProbeLinesB REGEX "${symbolRegexB}")
if (mglItestProbeLinesB)
set(mglItestProbeHitB "${mglItestProbeSource}")
endif()
endif()
endforeach()
if (mglItestProbeHitA AND mglItestProbeHitB)
set(${outVar} "${mglItestProbeHitA} + ${mglItestProbeHitB}" PARENT_SCOPE)
else()
set(${outVar} "" PARENT_SCOPE)
endif()
endfunction()
if (MOBILEGL_PIPE_PUSH)
# DirectGLES' Track H arm, probed by the subsystem bit it is gated on rather than by
# SlotTables.h existing: the bit is declared in the contract (MG_Pipe/MGPipe.h:77) and the
@@ -490,6 +552,91 @@ if (MOBILEGL_PIPE_PUSH)
"mpr entries will SKIP")
endif()
# P4a's THIRD re-key question, per backend, and it is not either of the two above. Bits 5/6
# re-keyed each backend's VERTEX-INPUT memos and bit 7/8 the BUFFER's; P4a re-keys six OBJECT
# classes - texture, renderbuffer, framebuffer, sampler CSO, sampler view, shader CSO - behind
# four new subsystem bits, and a backend has to NAME one of those constants to honour
# MOBILEGL_PIPE_PUSH's default mask. A P4a case that read either older marker would arm its
# Handles arm on a tree where nothing about a texture is keyed on a handle: green for a re-key
# that does not exist. Probed by the constants rather than by a file, for the reason the block
# above gives - packages D and E choose their own file layout.
foreach(mglItestObjectBackend DirectGLES DirectVulkan)
mgl_itest_probe_for_symbol(MGL_ITEST_OBJECT_REKEY
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/${mglItestObjectBackend}"
"kMGPipeSubsystem(Framebuffer|TextureResources|Samplers|Programs)")
if (MGL_ITEST_OBJECT_REKEY)
message(STATUS "Integration tests: ${mglItestObjectBackend} is keyed on {slot, gen} for "
"P4a's object families (${MGL_ITEST_OBJECT_REKEY})")
list(APPEND MGL_ITEST_CAPABILITY_ENV
"MGITEST_HANDLE_REKEY_OBJECTS_${mglItestObjectBackend}=1")
else()
message(STATUS "Integration tests: no ${mglItestObjectBackend} source names "
"kMGPipeSubsystem{Framebuffer,TextureResources,Samplers,Programs} - "
"HandleRecycle's six P4a cases will SKIP their Handles arm on it")
endif()
# ...and whether the ABA knob reaches those kinds THERE. A conjunction over the WHOLE
# backend directory, for the reason mgl_itest_probe_for_two_symbols states: reading the
# knob is not the same as steering P4a's keys with it, so the weaker single-regex evidence
# would turn six always-on entries into a permanent red - but requiring one FILE to carry
# both halves would let a backend satisfy the question and miss the probe, and the six
# controls would then keep asserting the correct pixels with nothing recording that the
# flip was forgotten (F-M5).
mgl_itest_probe_for_two_symbols(MGL_ITEST_OBJECT_ABA
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/${mglItestObjectBackend}"
"PipeHandleAbaControl"
"kMGPipeSubsystem(Framebuffer|TextureResources|Samplers|Programs)")
if (MGL_ITEST_OBJECT_ABA)
message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL steers "
"${mglItestObjectBackend}'s P4a object keys (${MGL_ITEST_OBJECT_ABA})")
list(APPEND MGL_ITEST_CAPABILITY_ENV
"MGITEST_HANDLE_ABA_OBJECTS_${mglItestObjectBackend}=1")
else()
message(STATUS "Integration tests: MobileGL/MG_Backend/${mglItestObjectBackend} does not "
"both read Features.PipeHandleAbaControl and name a P4a subsystem bit "
"somewhere under it - HandleRecycle's six P4a cases will assert the "
"CORRECT pixels on the AbaControl arm and say that it is not a control "
"for them yet")
endif()
endforeach()
# The client-side emitter of P4a's four suppressor-visible emission counters, which is what
# ObjectSubsystemControl reads. Same shape and same reason as the CSO and map-persistent
# emitter probes above: the emitters are minted in MG_Impl/Pipe (packages B and C), header-only
# by design (D-P), so the question is "does any source there emit it", not "does a named file
# exist". Until one does, every counter in the emit[] bracket is structurally zero in BOTH arms
# of the A/B and an assertion about their difference would be a statement about nothing.
mgl_itest_probe_for_symbol(MGL_ITEST_OBJECT_EMITTER
"${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "FramebufferEmissions")
if (MGL_ITEST_OBJECT_EMITTER)
message(STATUS "Integration tests: P4a's object emissions have an emitter "
"(${MGL_ITEST_OBJECT_EMITTER})")
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_OBJECT_EMITTER_PRESENT=1")
else()
message(STATUS "Integration tests: no MG_Impl/Pipe source emits FramebufferEmissions - "
"ObjectSubsystemControl's emission case will SKIP")
endif()
# ...and the CLIENT half of the texture-upload shape, separately, because it is a different
# question with a different owner's file behind it (review F-m7). ctu= is emitted by PipeStats
# in EVERY push build whether or not anything increments it, so "the counter read zero" and
# "no client emitter exists" are the same number and TextureUploadShape cannot tell them apart
# from the summary line alone. Once package B's texture emitter lands, an emitter that stopped
# emitting would read exactly like no emitter at all and the two-sided assertion the scenario
# exists for would pass while comparing nothing. This probe is what separates them.
mgl_itest_probe_for_symbol(MGL_ITEST_CLIENT_TEXTURE_UPLOAD_EMITTER
"${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "ClientTextureUploadEmissions")
if (MGL_ITEST_CLIENT_TEXTURE_UPLOAD_EMITTER)
message(STATUS "Integration tests: the client texture-upload counter has an emitter "
"(${MGL_ITEST_CLIENT_TEXTURE_UPLOAD_EMITTER}) - TextureUploadShape's "
"two-sided assertion is live")
list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_CLIENT_TEXTURE_UPLOAD_EMITTER_PRESENT=1")
else()
message(STATUS "Integration tests: no MG_Impl/Pipe source emits "
"ClientTextureUploadEmissions - TextureUploadShape records the SERVER shape "
"only and asserts that ctu= is zero")
endif()
mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_ABA
"${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "PipeHandleAbaControl")
if (MGL_ITEST_MAGMA_ABA)
@@ -502,8 +649,9 @@ if (MOBILEGL_PIPE_PUSH)
endif()
else()
message(STATUS "Integration tests: pull build - HandleRecycle.{Handles,AbaControl}, "
"CsoContentAddressing and ResourceSubsystemControl stay registered (G2) and SKIP: "
"every arm they assert is compiled only under MOBILEGL_PIPE_PUSH")
"CsoContentAddressing, ResourceSubsystemControl, ObjectSubsystemControl and "
"TextureUploadShape stay registered (G2) and SKIP: every arm they assert is "
"compiled only under MOBILEGL_PIPE_PUSH")
endif()
mgl_itest_join_environment(MGL_ITEST_GLES_ENVIRONMENT
@@ -676,6 +824,36 @@ gtest_discover_tests(MobileGLIntegrationTest
ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}"
)
# P4a final review C-2: the dirty-then-delete case, with the allocator SCRIBBLING every freed
# block. The defect this pins was a client emitter resolving a dead-but-not-recycled texture
# handle to the freed ITextureObject* and calling a virtual on it from the next verb's drain;
# whether that reads the object's ghost or faults depends on what the allocator did with the
# block, so the ambient registrations above run the case as the application would see it and
# these two run it with MALLOC_PERTURB_ set, where a resolved-but-dead pointer faults rather
# than passes. Both backends: the death path is backend-neutral by ruling (ID-8).
mgl_itest_join_environment(MGL_ITEST_GLES_MALLOC_PERTURB_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MALLOC_PERTURB_=165" ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_MALLOC_PERTURB_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MALLOC_PERTURB_=165" ${MGL_ITEST_VULKAN_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.MallocPerturb."
TEST_FILTER "P4aFinalFixScenario.ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_MALLOC_PERTURB_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.MallocPerturb."
TEST_FILTER "P4aFinalFixScenario.ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_MALLOC_PERTURB_ENVIRONMENT}"
)
# AsyncCompileScenario, with asynchronous compilation PINNED ON per backend.
#
# Not a duplicate of what the two ambient registrations already run: they run whatever
@@ -939,14 +1117,15 @@ gtest_discover_tests(MobileGLIntegrationTest
# explicit bitmask below that leaked PUSH=0 turned this lane's LEGACY_MEMOS=0 into D14's armless
# combination: the bring-up aborted, on purpose, and the lane went red for a reason that was never
# about handles. The mask is the PHASE default, not a hand-picked bit, so the lane keeps measuring
# the shape that ships: it was kMGPipeSubsystemsMigratedAtP2 (0x7f) and is now
# kMGPipeSubsystemsMigratedAtP3a (0x1ff, MG_Pipe/MGPipe.h), which adds bit 7 (resources) and bit 8
# (vertex input). Pinning it at 0x7f after P3a would leave the Handles arm asserting the P2 shape
# while the buffer and vertex-input handles it is supposed to be about stayed switched off - a lane
# that still passes and no longer measures the key that ships. Each phase's constant survives as the
# NEXT phase's A/B control, which is what ResourceSubsystemControl's Off lane uses 0x7f for.
# the shape that ships: it was kMGPipeSubsystemsMigratedAtP2 (0x7f), then
# kMGPipeSubsystemsMigratedAtP3a (0x1ff), and is now kMGPipeSubsystemsMigratedAtP4a (0x1fff,
# MG_Pipe/MGPipe.h), which adds bits 9-12 - framebuffer, texture resources, samplers and programs.
# Pinning it at 0x1ff after P4a would leave the Handles arm asserting the P3a shape while the six
# OBJECT handles it is now also about stayed switched off - a lane that still passes and no longer
# measures the key that ships. Each phase's constant survives as the NEXT phase's A/B control, which
# is what ResourceSubsystemControl's Off lane uses 0x7f for and ObjectSubsystemControl's uses 0x1ff.
if (MOBILEGL_PIPE_PUSH)
set(MGL_ITEST_HANDLES_ARM_KNOBS "MOBILEGL_PIPE_LEGACY_MEMOS=0" "MOBILEGL_PIPE_PUSH=0x1ff")
set(MGL_ITEST_HANDLES_ARM_KNOBS "MOBILEGL_PIPE_LEGACY_MEMOS=0" "MOBILEGL_PIPE_PUSH=0x1fff")
set(MGL_ITEST_ABA_ARM_KNOBS "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1")
else()
set(MGL_ITEST_HANDLES_ARM_KNOBS "")
@@ -1051,33 +1230,36 @@ gtest_discover_tests(MobileGLIntegrationTest
# only thing these four lanes are an A/B about is `no CSO content addressing` (bit 63,
# kMGPipeBehaviourNoCsoContentAddressing), which is what separates the On lane from the Off lane.
# Every
# other bit is the build's shipping mask, so it moves with the phase: it was 0x7f
# (kMGPipeSubsystemsMigratedAtP2) and is now 0x1ff (kMGPipeSubsystemsMigratedAtP3a) for the same
# reason MGL_ITEST_HANDLES_ARM_KNOBS above moved. Left pinned at 0x7f after P3a these lanes would
# keep passing while running on a configuration nothing ships - bits 7 (resources) and 8 (vertex
# input) cleared - which is the "still green, no longer measuring the shape that ships" failure
# the Handles-arm comment above rejects. The counters they read (csom / csob) are render-state and
# other bit is the build's shipping mask, so it moves with the phase: 0x7f
# (kMGPipeSubsystemsMigratedAtP2), then 0x1ff (kMGPipeSubsystemsMigratedAtP3a), now 0x1fff
# (kMGPipeSubsystemsMigratedAtP4a) for the same reason MGL_ITEST_HANDLES_ARM_KNOBS above moved. Left
# pinned at 0x1ff after P4a these lanes would keep passing while running on a configuration nothing
# ships - bits 9-12 (framebuffer, texture resources, samplers, programs) cleared - which is the
# "still green, no longer measuring the shape that ships" failure the Handles-arm comment above
# rejects. NOTE THAT THE OFF LANE'S MASK MOVED TOO, from 0x80000000000001ff to
# 0x8000000000001fff: the mask moves with the phase, the control is bit 63, and the two must not be
# confused. The counters they read (csom / csob) are render-state and
# are steered by neither bit, so raising the mask is behaviour-preserving for what they assert;
# what it buys is that a CSO regression that only shows up with the P3a subsystems on can reach
# them. (contract-review-v1.md item 11, closed here for all three lane families.)
mgl_itest_join_environment(MGL_ITEST_GLES_CSO_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=content-addressed"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_CSO_LANE=no-content-addressing"
"MOBILEGL_PIPE_PUSH=0x80000000000001ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_PIPE_PUSH=0x8000000000001fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=content-addressed"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-content-addressed-DirectVulkan.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_CSO_LANE=no-content-addressing"
"MOBILEGL_PIPE_PUSH=0x80000000000001ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_PIPE_PUSH=0x8000000000001fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/cso-no-content-addressing-DirectVulkan.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
@@ -1120,12 +1302,15 @@ gtest_discover_tests(MobileGLIntegrationTest
# --- G12: the P3a subsystem A/B, and G10's map-persistent-roundtrips lanes ------------
#
# THE MASKS ARE THE TWO PHASE CONSTANTS, not hand-picked bits: 0x1ff is
# kMGPipeSubsystemsMigratedAtP3a (the push build's default) and 0x7f is
# kMGPipeSubsystemsMigratedAtP2, which is exactly that default with bits 7 (resources) and 8
# (vertex input) cleared. MGPipe.h:79 keeps each phase's constant alive as the next phase's A/B
# control for this reason, and a lane that spelled its own bit pattern would stop being the shape
# that ships the first time the default moved.
# THE MASKS ARE PHASE CONSTANTS, not hand-picked bits: 0x1fff is
# kMGPipeSubsystemsMigratedAtP4a (the push build's default as of P4a) and 0x7f is
# kMGPipeSubsystemsMigratedAtP2, which is that default with bits 7 (resources), 8 (vertex input)
# and 9-12 (P4a's four object families) cleared. MGPipe.h keeps each phase's constant alive as the
# next phase's A/B control for this reason, and a lane that spelled its own bit pattern would stop
# being the shape that ships the first time the default moved. THE ON LANE MOVED TO 0x1fff WITH THE
# PHASE and the OFF LANE'S 0x7f DID NOT: this A/B is about bit 7, and pinning its On arm at 0x1ff
# after P4a would measure the resource emitter on a configuration nothing ships. P4a's own A/B -
# 0x1fff against 0x1ff - is ObjectSubsystemControl's, further down.
#
# DirectGLES only. P3a migrates Espryt's buffer and VAO paths; Magma's buffer path is P7 and
# registers no MGPipeResourceOps, so a DirectVulkan arm would be measuring the client emitter
@@ -1146,7 +1331,7 @@ gtest_discover_tests(MobileGLIntegrationTest
# the field inside the push guard), exactly as the HandleRecycle Legacy lanes already rely on.
mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=on"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-on-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT
@@ -1180,12 +1365,12 @@ gtest_discover_tests(MobileGLIntegrationTest
# TEST_FILTER, for the private-log reason above.
mgl_itest_join_environment(MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=storage-buffer-regrow"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-storage-buffer-regrow-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=large-arena-adoption"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/mpr-large-arena-adoption-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
@@ -1216,7 +1401,7 @@ gtest_discover_tests(MobileGLIntegrationTest
# four processes to truncate. The fourth case skips in both lanes for exactly that reason, saying
# so.
mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x1ff"
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x1fff"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=0x7f"
@@ -1241,6 +1426,191 @@ gtest_discover_tests(MobileGLIntegrationTest
ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT}"
)
# --- G12: the P4a subsystem A/B, and its dependency refusal ---------------------------
#
# THE MASKS ARE THE TWO PHASE CONSTANTS, exactly as the P3a block above: 0x1fff is
# kMGPipeSubsystemsMigratedAtP4a (the push build's default) and 0x1ff is
# kMGPipeSubsystemsMigratedAtP3a, which is that default with bits 9 (framebuffer), 10 (texture
# resources), 11 (samplers) and 12 (programs) cleared. THIS is the phase's "new 0x1ff off-lane": the
# constant P3a shipped survives as P4a's A/B control, and a lane that spelled its own bit pattern
# would stop being the shape that ships the first time the default moved.
#
# THE THIRD AND FOURTH LANES ARE THE DEPENDENCY REFUSALS (D-K2) and they are the entries in this
# family that are not vacuous before the emitters land, because both are decisions made from the
# bitmask alone. A half-honoured mask is invisible in the pixels by construction, which is why each
# needs an entry rather than a code comment.
#
# 0x9ff sets the sampler subsystem (bit 11) WITHOUT the texture resource subsystem (bit 10) that
# every MGPBoundView::Texture and MGPImageView::Res depends on.
# 0x5ff sets the texture resource subsystem (bit 10) WITHOUT the sampler subsystem (bit 11):
# D-K2's fourth row (ID-15). MGPTextureParams::BuiltinSampler is a SamplerCso handle and
# only bit 11 mints sampler CSOs, so bit 10 alone would emit a null there and the applier's
# Fatal is the next thing that happens. The brief called this pair harmless; P4a as built
# says otherwise, and the refusal belongs in the texture family's arm resolver.
#
# In both, the bring-up must log ONE error naming both bits and run the legacy arm.
#
# ONE CASE PER LANE, through TEST_FILTER, and it is a constraint rather than a preference: each case
# READS the library's log and the log is a per-LANE resource (the library opens it fopen(path, "w"),
# so every process in a lane truncates it). Two entries in one lane race under `ctest -j` with a
# failure indistinguishable from "the counter was never emitted". Same rule as the CSO lanes, the
# map-persistent lanes and the verify arming lane.
#
# THE FIRST FOUR LANES ARE DirectGLES ONLY. P4a migrates Espryt's framebuffer, texture, sampler
# and program paths; Magma's are P7 and register nothing here, so a DirectVulkan lane that measured
# the emit[] bracket would be measuring the client emitters against a backend nobody asked to
# change.
#
# THE FIFTH AND SIXTH LANES INVERT THAT, and the inversion is the point (c0f, ID-39/ID-40). "Magma
# registers no consumer" stopped being a reason to have no lane the moment it became a THING THE
# CLIENT MUST CHECK: P4a's families were wired without P3a's "a backend registered
# MGPipeResourceOps" conjunct, so on Magma the client emitted, the applier ACCEPTED, the client
# cleared its dirty flags on that acceptance and Magma's legacy path found nothing to upload - 66
# DirectVulkan cases red, every one of them texture-upload shaped, every one of them green at
# 0x1ff. The fix's belt (the applier refusing and counting RefusedNoConsumer) is invisible in
# pixels and invisible in the emit[] bracket, so it needs its own entry, and it needs it on the
# backend WITHOUT the consumer. The DirectGLES twin is the control that keeps the assertion from
# being vacuously true of a tree where nothing emits anywhere.
#
# Both run at the PHASE DEFAULT (0x1fff) rather than a hand-picked mask, for the reason the whole
# block gives: the shape that ships is the shape worth measuring.
#
# Registered in EVERY build, including the pull build where none of the counters exists, so that
# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a
# pull build MGITEST_PIPE_PUSH_BUILD is absent and every one of these cases skips saying so.
mgl_itest_join_environment(MGL_ITEST_GLES_OBJECT_SUBSYSTEM_ON_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_OBJECT_SUBSYSTEM_LANE=on"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/object-subsystem-on-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_OBJECT_SUBSYSTEM_OFF_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_OBJECT_SUBSYSTEM_LANE=off"
"MOBILEGL_PIPE_PUSH=0x1ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/object-subsystem-off-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_OBJECT_SUBSYSTEM_REFUSED_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_OBJECT_SUBSYSTEM_LANE=refused"
"MOBILEGL_PIPE_PUSH=0x9ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/object-subsystem-refused-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_OBJECT_SUBSYSTEM_REFUSED_TEXTURE_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_OBJECT_SUBSYSTEM_LANE=refused-texture"
"MOBILEGL_PIPE_PUSH=0x5ff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/object-subsystem-refused-texture-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_GLES_OBJECT_SUBSYSTEM_CONSUMER_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_OBJECT_SUBSYSTEM_LANE=consumer"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/object-subsystem-consumer-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
mgl_itest_join_environment(MGL_ITEST_VULKAN_OBJECT_SUBSYSTEM_NO_CONSUMER_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_OBJECT_SUBSYSTEM_LANE=no-consumer"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/object-subsystem-no-consumer-DirectVulkan.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ObjectSubsystemControl.On."
TEST_FILTER "ObjectSubsystemControlScenario.ClearingTheP4aBitsStopsTheEmissionsAndNotThePixels"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_OBJECT_SUBSYSTEM_ON_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ObjectSubsystemControl.Off."
TEST_FILTER "ObjectSubsystemControlScenario.ClearingTheP4aBitsStopsTheEmissionsAndNotThePixels"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_OBJECT_SUBSYSTEM_OFF_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ObjectSubsystemControl.Refused."
TEST_FILTER "ObjectSubsystemControlScenario.ASamplerBitWithoutTheTextureBitIsRefusedAndNamed"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_OBJECT_SUBSYSTEM_REFUSED_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ObjectSubsystemControl.RefusedTexture."
TEST_FILTER "ObjectSubsystemControlScenario.ATextureBitWithoutTheSamplerBitIsRefusedAndNamed"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_OBJECT_SUBSYSTEM_REFUSED_TEXTURE_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.ObjectSubsystemControl.Consumer."
TEST_FILTER "ObjectSubsystemControlScenario.TheAppliersNoConsumerBeltNeverFiresBehindTheClientsGate"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_OBJECT_SUBSYSTEM_CONSUMER_ENVIRONMENT}"
)
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectVulkan.ObjectSubsystemControl.NoConsumer."
TEST_FILTER "ObjectSubsystemControlScenario.TheAppliersNoConsumerBeltNeverFiresBehindTheClientsGate"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_VULKAN_OBJECT_SUBSYSTEM_NO_CONSUMER_ENVIRONMENT}"
)
# --- The texture upload shape: RECORDED, NOT GATED in P4a (D-D4) ----------------------
#
# SSIM is blind to the box-versus-rect upload shape and the Mali cliff it hides is ~+6 ms/frame, so
# the shape needs a number - two numbers, in fact, the server's tex[emit= box= rect= jobs=] and the
# client's emit[ctu=], which agreeing is the whole reason both are published (D-L). What this lane
# asserts is that the numbers could be READ, that the server bracket's own arithmetic holds and that
# the two sides agree when both are non-zero; WHICH shape each texture took is RecordProperty'd and
# printed for MEASUREMENTS.md. P3b/P4b turns it into a gate against a gold standard, with the Mali
# frame-time delta published beside it - gating a shape this phase has not finished deciding would
# either pin today's shape as the answer or fail on the change that is the next phase's point.
#
# Its own log path and its own single-case filter, for the per-lane-log reason above.
mgl_itest_join_environment(MGL_ITEST_GLES_TEXTURE_UPLOAD_SHAPE_ENVIRONMENT
"MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_TEXTURE_UPLOAD_SHAPE_LANE=1"
"MOBILEGL_PIPE_PUSH=0x1fff" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1"
"MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/texture-upload-shape-DirectGLES.log"
${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV})
gtest_discover_tests(MobileGLIntegrationTest
TEST_PREFIX "DirectGLES.TextureUploadShape."
TEST_FILTER "TextureUploadShapeScenario.TheEmittedUploadShapeIsRecordedAndTheTwoSidesAgree"
DISCOVERY_TIMEOUT 30
PROPERTIES
LABELS integration-gpu
TIMEOUT ${MGL_ITEST_TIMEOUT}
ENVIRONMENT "${MGL_ITEST_GLES_TEXTURE_UPLOAD_SHAPE_ENVIRONMENT}"
)
# --- G9: the mandatory red-before scenario -------------------------------------------
#
# TextureParamsWithoutASamplerViewScenario needs NO lane of its own, and that is deliberate rather
# than an omission. It reads no counter and no log - its observable is a sampled colour - and its
# claim is about the SHIPPING configuration, so the two ambient registrations at the top of this
# file (which run at the build's default mask) are exactly the arms it wants. A lane here would pin
# a mask and make the entry stop describing what ships the next time the default moved. The
# DirectVulkan ambient entries skip in the scenario's SetUp, naming the backend: the gap it is about
# is Espryt's SyncAttachmentObject / SyncNeccessaryTextures pair and P4a touches no DirectVulkan
# source but MagmaPipeArms.h (D-Q).
#
# D-E3 expects its second case, AReadAttachmentOnlyTexturesDepthStencilModeReachesTheDriver, to be
# RED until package esprytobj lands. MEASURED ON THE CONTRACT COMMIT IT IS GREEN, and the scenario's
# header carries the mechanism: a texture parameter's only public-GL observable is a sample, and the
# sample repairs the state it was meant to catch (the unit sync list re-syncs whenever the params
# version moved). Nothing about any of that is expressed in this file - a registration that
# "expected" a red would be a gate that could never go green - and the ruling on the missing
# artefact is the integrator's, recorded in the gates result document.
if (MOBILEGL_PIPE_VERIFY)
# 900s, not the ambient 120: the comparator re-reads every field of the fill mask at the verb
# boundary and again at every accessor read, which the design budgets at 5-10x.
@@ -0,0 +1,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
@@ -20,11 +20,24 @@ 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;
default: return MobileGL::MG_Pipe::MGPipeKind::VertexElementsCso;
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
@@ -36,12 +49,34 @@ namespace MGITest {
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
@@ -30,6 +30,36 @@ namespace MGITest {
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.
@@ -41,4 +71,31 @@ namespace MGITest {
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
File diff suppressed because it is too large Load Diff
@@ -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,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
+46
View File
@@ -195,6 +195,46 @@
// 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) \
@@ -210,8 +250,11 @@
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) \
@@ -221,11 +264,14 @@
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) \
+95 -5
View File
@@ -154,6 +154,21 @@
// 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)
@@ -279,6 +294,39 @@
/* 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 */ \
@@ -296,9 +344,24 @@
/* 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, kUnpublishedDestroy) \
X(MarkProgramForDeletion, kExplicitDestroy) \
X(MarkProgramPipelineForDeletion, kUnpublishedDestroy) \
X(MarkRenderbufferObjectForDeletion, kExplicitDestroy) \
X(MarkSamplerObjectForDeletion, kExplicitDestroy) \
@@ -326,9 +389,36 @@
// UNDECIDED, each with the reason --check prints for it. Every bit answer NOT listed here
// is marked derived: --check fails when the derivation cannot decide it, and fails again
// when a mark here names a pair the derivation now decides, so this list can neither hide a
// row nor outlive its reason. Empty today: every bit answer above is supported at field
// level. The ten mutators that reach a tainted body (--check prints the count) all carry a
// prose answer, which no derivation checks.
#define MGP_DIRTY_SURFACE_UNDECIDED_LIST(X)
// 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
+32 -1
View File
@@ -83,7 +83,30 @@ namespace MobileGL::MG_Pipe {
// the other.
inline constexpr Uint64 kMGPipeSubsystemResources = 1ull << 7;
inline constexpr Uint64 kMGPipeSubsystemVertexInput = 1ull << 8;
// bits 9..62 reserved for the later phases, allocated in ROADMAP order.
// 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).
@@ -93,6 +116,14 @@ namespace MobileGL::MG_Pipe {
// "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);
+546 -24
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,19 +298,99 @@ 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;
// THE SCOPE OF A RESPECIFY (P5 R-13.3b, CONTRACT-P5.md table 1 row 19b). Zero means
// "this respecify redefines the WHOLE resource"; non-zero means it redefines exactly
// the (RespecifiedUploadTarget, RespecifiedLevel) pair below and nothing else.
//
// It exists because the applier ALREADY takes that scope - as a trailing
// const MGPRespecifiedLevel* (PipeApply.h:792-795) - and MGPResourceDesc could not
// express it, so it was the second of resource_respecify's two companions with no wire
// carrier. The difference is not cosmetic: a null scope drops EVERY pending upload,
// because every level's coordinate system has just been replaced, while a per-level
// one drops only that key. Crossing without the scope would make every mutable
// per-level glTexImage*D on the far side look like a whole-resource redefinition and
// silently eat the texels of every other level - exactly the loss the server-side
// pending-upload set exists to prevent.
//
// READ IT THROUGH THE THREE HELPERS BELOW, never by touching the fields: the
// presence byte and the pair are one value in three pieces, and an open-coded reader
// that forgets the presence byte reads level 0 of upload target 0 as a real scope.
//
// NOT STORAGE-DEFINING, and not metadata either: it does not describe the resource at
// all, it describes what this CALL replaces. MGPipeResourceRespecifyNeedsAck and the
// storage-defining field set below are unaffected by it, which is why it is not named
// in either.
Uint8 HasRespecifiedLevel;
Uint8 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.
Uint32 GlNameForDiag;
Uint32 Pad1;
// The pair HasRespecifiedLevel above gates. Byte for byte the two members of
// MGPRespecifiedLevel (PipeApply.h:792-795), in that order, so the applier's existing
// struct and this carrier are one layout - but spelled as two Uint16s rather than as
// that type, because PipeApply.h includes THIS header and not the other way round.
Uint16 RespecifiedUploadTarget;
Uint16 RespecifiedLevel;
MGPipeHandle ViewOf; // storage owner for a texture view
MGPipeHandle BufferForTexBuffer; // texture-buffer backing store
Uint64 BufOffset, BufSize; // kWholeBuffer == ~0, resolved live
};
MGP_ASSERT_POD(MGPResourceDesc, 88);
// The scope fields went into the two existing pads, so the descriptor did not grow and this
// number did not move. That was the deciding argument against giving MGPResourceDesc an
// MGPBlobRef for the OTHER uncarried companion (initialBytes): a blobref is 24 bytes, the
// pads are 6, and it would have taken the struct to 112. The scope needs 5 and fits.
static_assert(offsetof(MGPResourceDesc, RespecifiedUploadTarget) + sizeof(Uint16) ==
offsetof(MGPResourceDesc, RespecifiedLevel),
"the respecify scope pair must stay adjacent and in MGPRespecifiedLevel's order");
// ---- the only supported reads of the respecify scope --------------------------------
//
// Three fields, one value. Open-coding them is how a reader that forgets the presence byte
// turns "whole resource" into "upload target 0, level 0" - a real scope, and the wrong one.
// True when this respecify replaces the whole resource, which is every glBufferData, every
// glBufferStorage, every glTexStorage* and every texture view. The applier drops every
// pending upload for it.
inline constexpr Bool MGPipeRespecifyIsWholeResource(const MGPResourceDesc& desc) {
return desc.HasRespecifiedLevel == 0;
}
// The single (uploadTarget, level) a per-level respecify replaces. Reading either half of a
// whole-resource descriptor is a caller error; both answer 0 so that a misuse is at least
// deterministic rather than whatever the pad happened to hold.
inline constexpr Uint16 MGPipeRespecifiedUploadTargetOf(const MGPResourceDesc& desc) {
return MGPipeRespecifyIsWholeResource(desc) ? Uint16(0) : desc.RespecifiedUploadTarget;
}
inline constexpr Uint16 MGPipeRespecifiedLevelOf(const MGPResourceDesc& desc) {
return MGPipeRespecifyIsWholeResource(desc) ? Uint16(0) : desc.RespecifiedLevel;
}
// The two writers. A producer sets the scope with one call so the presence byte cannot be
// left behind, and clears it with the other; a descriptor built by value-initialization is
// already whole-resource, which is the safe default and the only one P5 produces.
inline constexpr void MGPipeSetRespecifiedLevel(MGPResourceDesc& desc, Uint16 uploadTarget,
Uint16 level) {
desc.HasRespecifiedLevel = 1;
desc.RespecifiedUploadTarget = uploadTarget;
desc.RespecifiedLevel = level;
}
inline constexpr void MGPipeClearRespecifiedLevel(MGPResourceDesc& desc) {
desc.HasRespecifiedLevel = 0;
desc.RespecifiedUploadTarget = 0;
desc.RespecifiedLevel = 0;
}
inline constexpr Uint64 kMGPipeWholeBuffer = ~0ull;
struct MGPFenceWait {
@@ -304,18 +521,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
@@ -338,35 +586,183 @@ 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);
@@ -378,6 +774,20 @@ namespace MobileGL::MG_Pipe {
// 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;
@@ -617,6 +1027,30 @@ 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.
@@ -635,6 +1069,8 @@ namespace MobileGL::MG_Pipe {
// 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?
@@ -647,6 +1083,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) {
@@ -675,12 +1143,66 @@ namespace MobileGL::MG_Pipe {
// is ((void)0) - the applier is one function call away - and the transport wires the
// doorbell to this predicate when it lands.
//
// Immutable is exactly the right discriminator: it is set iff the store came from a
// glBufferStorage* entry point, which is the definition of the allowed case.
// 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;
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 {
File diff suppressed because it is too large Load Diff
+667 -3
View File
@@ -28,6 +28,17 @@
// (MG_Impl/Pipe) already have it, and MG_Pipe sits below MG_Backend.
//
// Compiled only under MOBILEGL_PIPE_PUSH (CMakeLists.txt), so the pull build gains no symbol.
// P4a: create_shader_state carries the reflection ARCHIVE, and in monolith the archive does
// not travel - the two structs ride beside the record through the entry point's companion
// pointers, exactly as P3a's `const void* initialBytes` does (D-H3, the one Blob rule). So
// this header needs their NAMES and never their definitions; the forward declaration is the
// whole coupling and the closure gate is what keeps it one. The verify build is the only
// place the codec runs, and it runs from PipeApply.cpp.
namespace MobileGL::MG_State::GLState {
struct LinkArtifacts;
struct SpirvArtifacts;
} // namespace MobileGL::MG_State::GLState
namespace MobileGL::MG_Pipe {
struct PipeInputs;
@@ -109,8 +120,91 @@ namespace MobileGL::MG_Pipe {
// own dense high-water mark and no further, so the bound costs nothing until a record is
// already corrupt. Package C bounds handle.Slot the same way before
// BackendSlotTable::EntryAt, which resizes on a client-supplied index too.
// P4a: THE BOUND IS PER KIND, not per table, and that is what keeps one number honest
// while the number of tables grows. The slot spaces of kinds Buffer, Texture and
// Renderbuffer are INDEPENDENT (MGPipeSlotAllocator allocates per kind), so three
// different objects can hold slot 7; the applier therefore keeps one Vector per resource
// KIND and indexes it by slot, rather than one Vector indexed by slot alone. Each is
// bounded by kMGPipeMaxResourceSlots and each grows only to its own dense high-water mark.
inline constexpr Uint32 kMGPipeMaxResourceSlots = 1u << 20;
inline constexpr Uint32 kMGPipeMaxVertexElementsSlots = 1u << 16;
// P4a's three, and the argument is written out for each because the records differ in
// size. None is ever allocated by being named: the tables grow to the client's own dense
// high-water mark and no further, so the bound costs nothing until a record is corrupt.
//
// A sampler CSO record is a 100-byte value plus a handle, and sampler CSOs are
// CONTENT-ADDRESSED at capacity 256 on the client, so the live population is bounded by
// that cache and not by the application. 1<<16 is far above anything a GL program can hold
// and small enough that a corrupt slot is refused rather than allocated.
inline constexpr Uint32 kMGPipeMaxSamplerCsoSlots = 1u << 16;
// A sampler VIEW is identity-addressed one per ITextureObject (P4a D-F2), so its
// population tracks the texture population exactly and it takes the texture bound.
inline constexpr Uint32 kMGPipeMaxSamplerViewSlots = 1u << 20;
// The shader-CSO bound is the SLOT LIMIT ITSELF, because the composite band lives inside
// that space (MGPipeHandles.h): a bound below it would refuse the very slots
// AllocateComposite is allowed to hand out.
inline constexpr Uint32 kMGPipeMaxShaderCsoSlots = kMGPipeShaderCsoSlotLimit;
static_assert(kMGPipeMaxShaderCsoSlots > kMGPipeShaderCsoCompositeSlotBase,
"the ShaderCso bound must contain the composite band, or a composite handle "
"is refused as out of range on arrival");
// ID-19(b): the framebuffer record is now PER OBJECT and its table is slot-indexed like the
// five above, so it takes a bound on the same terms. A framebuffer record is 304 bytes and
// an FBO is a CONTAINER object - not shared between contexts, minted a few dozen at a time
// by a renderer and a few hundred by a shader pack - so 1<<16 is orders of magnitude above
// any live population and still turns a corrupt Uint32 into a refusal rather than a
// 4-billion-entry resize.
inline constexpr Uint32 kMGPipeMaxFramebufferSlots = 1u << 16;
// THE FOURTH set_framebuffer_state TARGET is the contract's MGPipeFramebufferTarget::Named (c0e):
// "this record describes the framebuffer it names; no binding changes." Draw / Read / Both
// write the record AND set the bound handle(s); Named writes the record only, which is how
// the DSA entry points - BlitNamedFramebuffer and the four ClearNamedFramebuffer* - hand the
// server a record for a framebuffer bound to neither binding (esprytobj review C-1, ID-19).
// The two framebuffer BINDINGS, and there are two rather than three: Both and Named are
// things a RECORD says, not bindings a server has. MGPipeApplierState::BoundFramebuffer is
// indexed by MGPipeFramebufferTarget::Draw / ::Read, which is what makes package D's
// "is this framebuffer the one bound to target t" one array compare (ID-19(d)).
inline constexpr Uint32 kMGPipeFramebufferBindingCount = 2;
static_assert(static_cast<Uint8>(MGPipeFramebufferTarget::Draw) == 0 &&
static_cast<Uint8>(MGPipeFramebufferTarget::Read) == 1,
"BoundFramebuffer is indexed by the target byte; Draw and Read must be 0 and 1");
// ---- P4a's SHAPE bounds, and they are the same argument the slot bounds above make, one
// level down: every number below arrives inside a payload, every one of them decides how
// much the applier allocates or how far it indexes, and NONE of them is ever allocated by
// being named. A record that names one past its bound is Fatal{ProtocolCorruption} - the
// verdict this file reserves for a record that would make the server act outside its own
// storage - and never a resize.
// A sub-data record's mip level. GL's own bound is log2 of the maximum texture size, which
// no device reports above 2^16, so a level index of 32 addresses a texture no
// implementation can allocate and is a corrupt record rather than a large one. It is NOT
// MGPTextureParams::MaxLevel's bound: GL_TEXTURE_MAX_LEVEL defaults to 1000 and is a
// parameter, not a storage level, so nothing here polices it.
inline constexpr Uint16 kMGPipeMaxTextureLevels = 32;
// The pending-upload set (below) is keyed by (UploadTarget, Level) and both halves come
// off the wire. Levels are bounded above; upload targets are not - a cube face, an array
// target and a rectangle target are all legal values - so the number of DISTINCT keys one
// resource may accumulate is bounded here. Six cube faces times 32 levels is 192; 256
// leaves room for a target space this phase has not enumerated and still refuses the
// unbounded growth a corrupt Uint16 would otherwise buy.
inline constexpr Uint32 kMGPipeMaxPendingUploads = 256;
// The rect list behind one pending entry. The frontend keeps at most MipmapStorage's
// kMaxDirtyRects = 96 per level and answers "0 rects" for everything it cannot describe
// that way, which is the model this mirrors: an accumulation that would exceed this
// collapses to BOX ONLY - the same answer, with the same meaning, and never a dropped
// region. 256 is that bound with room for several emissions accumulating behind a bail.
inline constexpr Uint32 kMGPipeMaxPendingUploadRegions = 256;
// The default uniform block's image, the one allocation P4a adds per program. The size
// comes from the program's own MGPProgramDesc::GlobalUboSize, so it is checked ONCE at
// create_shader_state and the set_global_constants that follows can only allocate what the
// create already declared. 16 MiB is four orders of magnitude above any default uniform
// block a real program links and still turns a corrupt Uint32 into a refusal.
inline constexpr Uint32 kMGPipeMaxGlobalConstantsBytes = 16u << 20;
// One record per live resource, indexed by MGPipeHandle::Slot, kind Buffer; slot 0 is the
// reserved null handle and is never live.
@@ -129,6 +223,126 @@ namespace MobileGL::MG_Pipe {
// persistent-mapped host writes can set it with zero new record kinds; a verify build
// pins that it is false, so that phase cannot land a silent semantic change under it.
Bool HasLiveHostWrites = false;
// ---- P4a. Only a record of kind Texture ever carries these; a buffer's stay at
// their defaults, which is what keeps ONE record type for the discriminated
// descriptor rather than a second one that would have to be kept in step with it.
// set_texture_params, per texture OBJECT and independent of any binding - which is
// the whole point of addressing it by resource: a texture that is only an FBO
// attachment, only an image-unit binding or only a glCopyImageSubData endpoint has no
// sampler view to hang its parameters on, and today the READ-attachment case reaches
// no parameter push at all. ParamsSerial replaces the twin's
// m_syncedTextureParamsVersion + m_forceTextureParamsResync pair.
//
// Params.BuiltinSampler MAY NAME A CSO WHOSE RECORD IS GONE. set_texture_params
// deliberately does not resolve it (the sampler CSO is content-addressed and shared,
// D-F1, and the ordering between the two families is the emitter's), and
// delete_sampler_state does not sweep the textures that name the CSO it drops. So a
// consumer that follows this handle must expect SamplerCsos[slot] to be dead or
// recycled and treat that as it treats any other stale handle - it is an ordering fact
// about the two emitters, not a corrupt record.
MGPTextureParams Params{};
Uint64 ParamsSerial = 0;
// The SamplerViewCso minted for this texture (P4a D-F2: one per ITextureObject,
// re-issued on the same handle whenever the restrictions move).
MGPipeHandle ViewCso = kMGPipeNullHandle;
// THE PENDING-UPLOAD SET, and it is server-side state on purpose (D-D5). The client
// clears its own dirty flags at EMISSION, for the levels whose record the applier
// accepted; Espryt's upload loop has bail arms - an incomplete texture returns early,
// a multisample target refreshes and skips - that today leave the frontend flag set,
// so a naive move of the clear to the client would lose those texels. The applier
// accumulates the emitted shape here instead, it survives any number of bails, and
// Espryt consumes and clears an entry only where it actually uploads.
//
// The verify lane's RETAIN MODE is what gates the shape: a consume-and-clear set
// cannot be recomputed after emission, so the tracker retains the pre-clear set and
// the comparator compares the emitted (UnionBox, RegionCount, Regions[]) against it
// field by field.
//
// THE SET IS KEYED (UploadTarget, Level) AND EVERY KEY IS INDEPENDENT OF EVERY OTHER.
// That is not a detail: a respecify redefines ONE level when it arrives from
// glTexImage*D (MGPipeApplyResourceRespecify's trailing MGPRespecifiedLevel*), so it
// may only drop that one key - the frontend's AllocateStorage / MarkStorageDirty are
// per (uploadTarget, level) too, and the other levels' dirty flags were cleared at
// THEIR emission, so nothing anywhere still owes them.
//
// THE ACCUMULATED RECT LIST MAY OVERLAP, AND A CONSUMER MUST TOLERATE THAT. Behind one
// level the frontend's own model is pairwise disjoint (MipmapStorage keeps it so), but
// this list CONCATENATES the lists of successive emissions and the applier's gate only
// asks that each rect be inside the record's own union box - so two emissions that
// touch the same texels leave two rects that do. Staging N rects therefore uploads
// those texels twice, which is a cost and never a correctness problem; nothing here
// de-duplicates and nothing downstream may assume "the frontend's model" means disjoint
// once the shapes have been accumulated.
struct PendingUpload {
Uint16 UploadTarget = 0;
Uint16 Level = 0;
MGPBox UnionBox{};
Vector<MGPSubRegion> Regions;
};
Vector<PendingUpload> PendingUploads;
};
// ---------------------------------------------------------------------------------
// P4a: the three new object-record kinds (D-J1)
// ---------------------------------------------------------------------------------
//
// All three follow MGPipeResourceRecord's shape exactly - Gen, Live, a payload and a
// server-owned monotone Serial - because the body-level idioms are the same ones:
// a create starts the record OVER rather than editing it (a recycled slot's record must
// not contribute one field, and Serial stays 0 because a create is not a mutation, so a
// fresh backend twin starting at 0 agrees without either side publishing anything); the
// serial moves BEFORE the backend is told; a destroy drops the record whole and keeps the
// generation, and the CLIENT frees the slot afterwards.
// create_sampler_state / delete_sampler_state. The parameters cross byte for byte
// INCLUDING borderColorForm - all three border representations are always numerically
// populated, so the value alone cannot say which driver entry point to use - and
// MOBILEGL_PIPE_VERIFY compares them FIELD BY FIELD (PipeFields.def's
// MGP_FIELDS_SamplerParameters), because the struct has three bytes of trailing padding
// and a byte comparison of it is a coin flip rather than a gate.
struct MGPipeSamplerCsoRecord {
Uint32 Gen = 0;
Bool Live = false;
SamplerParameters Params{};
Uint64 Serial = 0;
};
// create_sampler_view / delete_sampler_view: ONLY the view restrictions. Everything a
// glTexParameter writes lives on set_texture_params instead. Re-issuing on the same
// handle is how a restriction change travels (Gen moves only on slot reuse); it bumps
// Serial and does not rebind anything.
struct MGPipeSamplerViewRecord {
Uint32 Gen = 0;
Bool Live = false;
MGPSamplerView View{};
Uint64 Serial = 0;
};
// create/bind/delete_shader_state, plus set_global_constants' per-program half.
//
// THE ARTEFACTS ARE NOT HELD HERE IN MONOLITH: MGPProgramDesc's seven MGPBlobRefs are all
// declared with Size 0 ("this record does not declare its blob") and the LinkArtifacts /
// SpirvArtifacts ride beside the record through the entry point's companion pointers, so
// the applier stores the DESCRIPTOR and the identity and the server reads the frontend's
// own archive. That is what keeps the codec off the monolith hot path entirely; the verify
// build is where it is exercised, by serialising, deserialising and field-comparing before
// storing.
//
// GlobalConstants is the one allocation P4a adds per program, it is bounded by
// Desc.GlobalUboSize, and it is NOT on the hot path: set_global_constants is
// (ShaderCso, Version) keyed and fires at most once per program per frame.
struct MGPipeShaderCsoRecord {
Uint32 Gen = 0;
Bool Live = false;
MGPProgramDesc Desc{};
// GetUBOContentVersion() as last received. ~0u is the backends' "never uploaded"
// sentinel and the client must never emit it, so it is also what this starts at.
Uint32 GlobalConstantsVersion = ~Uint32{0};
Vector<Uint8> GlobalConstants;
Uint64 GlobalConstantsSerial = 0;
Uint64 Serial = 0;
};
// The vertex-elements CSO as the applier holds it: the unpacked blob, both views, plus
@@ -151,6 +365,27 @@ namespace MobileGL::MG_Pipe {
Uint64 ContentSerial = 0;
};
// set_framebuffer_state's record, HELD PER FRAMEBUFFER OBJECT and indexed by the handle's
// slot (ID-19(b)). It is the one record kind in this file whose object has NO WIRE LIFETIME:
// the catalogue has no framebuffer create and no framebuffer destroy, because a framebuffer
// is state and set_framebuffer_state is the only call that names one (D-I2). So there is
// nothing to mark dead and nothing to refuse against, and a slot is simply OVERWRITTEN by
// its successor's record - which is correct rather than merely tolerable, since the record
// that reaches this table is the description of whatever object holds the slot NOW.
//
// `Live` is therefore NOT a lifetime. It means "a record has been written at this slot",
// which is the only question a reader can ask: it separates a table entry that exists
// because the vector grew past it from one an emission actually wrote. The GENERATION is
// still checked on every lookup (P3a contract-review M2), and a mismatch is a LOUD refusal -
// it means an emitter handed a stale handle, or minted a successor without describing it,
// which is exactly the seam defect the DSA arm would otherwise turn into a blit into a
// driver framebuffer with no attachments.
struct MGPipeFramebufferRecord {
Uint32 Gen = 0;
Bool Live = false;
MGPFramebufferState State{};
};
struct MGPipeApplierState {
// Indexed by slot; slot 0 is the reserved null handle and is never live
// (MGPipeHandles.h kMGPipeFirstAllocatableSlot).
@@ -217,6 +452,43 @@ namespace MobileGL::MG_Pipe {
Vector<MGPipeResourceRecord> Resources;
Vector<MGPipeVertexElementsRecord> VertexElementsCsos;
// ---- P4a's object records. FIVE MORE TABLES, and the two resource ones are separate
// Vectors rather than more rows of `Resources` above because the slot space is PER
// KIND: a Buffer, a Texture and a Renderbuffer can all hold slot 7 at once, so a
// single slot-indexed table would alias three different objects onto one record. The
// record TYPE is shared - one discriminated descriptor for buffers, every texture
// target and renderbuffers - and the bound is shared; only the table is per kind.
//
// Like the two above they are share-group state: MGPipeApplierReset does not touch
// them, and only the object's own death signal and MGPipeApplierReleaseObjectRecords
// clear them.
Vector<MGPipeResourceRecord> TextureResources;
Vector<MGPipeResourceRecord> RenderbufferResources;
Vector<MGPipeSamplerCsoRecord> SamplerCsos;
Vector<MGPipeSamplerViewRecord> SamplerViewCsos;
Vector<MGPipeShaderCsoRecord> ShaderCsos;
// The ShaderCso COMPOSITE band's records, indexed by (slot - the band's base), for the
// same reason MGPipeSlotAllocator keeps the band in a table of its own: the band
// starts at 983040, so one program-pipeline composite in the slot-indexed vector above
// would grow it to ~983k records of ~240 bytes each. THE SERVER STILL NEVER LEARNS IT
// IS A COMPOSITE - the split is an indexing detail on this side of the wire, the
// handle is an ordinary ShaderCso handle, and create/bind/delete_shader_state name it
// exactly as they name any other program.
Vector<MGPipeShaderCsoRecord> CompositeShaderCsos;
// AND THE SIXTH, WHICH IS THE ONE ID-19 ADDED. Keyed by the FRAMEBUFFER HANDLE's slot,
// for the reason MGPipeFramebufferRecord states: the two bound-target records the phase
// started with could not describe a framebuffer that is bound to neither binding, and
// the five DSA entry points (BlitNamedFramebuffer, the four ClearNamedFramebuffer*) hand
// Espryt exactly that.
//
// IT IS AN OBJECT TABLE AND IT LIVES WHERE THE OTHER OBJECT TABLES LIVE, which is also
// its make-current rule: MGPipeApplierReset does NOT clear it. An FBO is not shared
// between contexts, but its record is addressed by a slot out of one global allocator,
// so nothing aliases across a switch - and dropping the table would leave a
// DSA-only framebuffer with no record and no event that would ever re-emit one (the
// client's suppressor invalidation re-emits the two BOUND records and nothing else).
Vector<MGPipeFramebufferRecord> FramebufferRecords;
// Every call this applier REFUSED because it named a record this applier does not
// have: an unknown slot, a slot that is not live, or a generation that has moved on
// under it. The refusal is a defined no-op - nothing stored, nothing dispatched, no
@@ -227,6 +499,58 @@ namespace MobileGL::MG_Pipe {
// build. Per context, like the four render-state wire counters above.
Uint64 RefusedResourceCalls = 0;
Uint64 RefusedVertexInputCalls = 0;
// P4a's, in the same shape and for the same reason: every sampler, sampler-view,
// program and texture-params call this applier refused because it named a record this
// applier does not have. One counter rather than four, because the families share one
// legal refusal sequence (teardown -> MGPipeApplierReleaseObjectRecords -> ~Object ->
// death notices naming records already dropped) and an operator reading a log wants to
// know that ANY object call was dropped; the log line names the call and the handle.
//
// set_framebuffer_state IS DELIBERATELY NOT ON THAT LIST AND CANNOT BE. D-I2 gives a
// framebuffer a handle and NO wire lifetime, so the call resolves no record - there is
// nothing to look up, nothing to find missing and therefore nothing to refuse - and
// MGPSurface::Res is likewise left unresolved on purpose (D-I3: the keep-alives are the
// frontend's SharedPtrs and enforcing them is a later phase's). Its only verdict is
// Fatal{ProtocolCorruption} on a malformed record, and this counter must stay at 0
// across every framebuffer call in every build. ID-19(b) does not change that: the
// per-object table is WRITTEN by that call and never looked up by it, and the refusal
// that the table CAN produce - a lookup whose generation has moved on - happens on the
// server's own read path and is counted apart, in StaleFramebufferRecordLookups.
//
// THE OTHER CLASS IS NOT COUNTED HERE AND MUST NOT BE: a var-tail window outside its
// bound, or a set_texture_params whose BuiltinSampler is the null handle, would make
// the backend act outside its own storage or sample an object that does not exist -
// that is Fatal{ProtocolCorruption}, not a dropped call.
Uint64 RefusedObjectCalls = 0;
// P4a's BELT (ID-39): every call in one of the four families P4a migrates that this
// applier declined because NO BACKEND HAS REGISTERED MGPipeResourceOps - i.e. because
// nothing in this process consumes what the record publishes.
//
// WHY THE APPLIER ASKS A QUESTION ABOUT THE BACKEND AT ALL, when it is otherwise
// backend-neutral: acceptance is a CONTRACT WITH THE CLIENT since ID-18 M3. The
// emitters clear a texture level's dirty flags, advance their descriptor mirrors and
// latch their suppressors on the answer this applier returns, so an applier that
// accepts a record nothing will ever read makes the client forget work the legacy pull
// path still owed - which is exactly how 66 texture-upload-shaped DirectVulkan cases
// went red on the push build (ID-39). The client's own gate
// (MG_Impl/Pipe/PipeFill.cpp's FamilyIsLive) stops the emission upstream; this is the
// belt under it, so a record that reaches here by any other route - GL_Framebuffer.cpp's
// PipePublishFramebufferByName calls its emitter directly, without passing PipeFill -
// is declined rather than accepted.
//
// IT IS NOT A DEFECT COUNTER, WHICH IS WHY IT IS SILENT. RefusedResourceCalls,
// RefusedVertexInputCalls and RefusedObjectCalls each mean "a record named something
// this applier should have had"; a non-zero value there is a seam defect. A non-zero
// value HERE is the designed steady state of a backend with no P4a twins, so logging it
// would put an ERROR line in every ordinary Magma run. The number is the observable.
//
// THE DEATH PATHS ARE DELIBERATELY NOT ON THIS LIST. resource_destroy,
// delete_sampler_state, delete_sampler_view and delete_shader_state are idempotent
// cleanup that must keep working whatever the registration did, and with no consumer
// there is no record for them to find anyway (they count their own refusal). Per
// context and cleared by MGPipeApplierReset, like the three above it.
Uint64 RefusedNoConsumer = 0;
// ---- working state: what the next draw fetches with. All of it is per context and
// all of it is cleared by MGPipeApplierReset, EXCEPT the two serials, which only ever
@@ -271,6 +595,89 @@ namespace MobileGL::MG_Pipe {
// operator greps is PipeStats' map-persistent-roundtrips (mpr); this member is the
// applier-side observable a unit case reads without a stats window.
Uint64 MapPersistentRoundtrips = 0;
// ---- P4a's WORKING state. All of it is per context and all of it is cleared by
// MGPipeApplierReset, EXCEPT the serials, which only ever advance - a counter that
// restarts walks back through values already stamped into a twin that outlived the
// switch, and P4a deletes the identity patches that used to close that hole.
// WHICH FRAMEBUFFER IS BOUND TO EACH BINDING, and that is ALL this pair is since
// ID-19(b): the record itself lives in FramebufferRecords above, keyed by the handle.
// Indexed by MGPipeFramebufferTarget::Draw / ::Read. kMGPipeNullHandle means "nothing
// described this binding yet", which is what a make-current leaves behind.
//
// set_framebuffer_state Draw / Read / Both writes the RECORD at state.Fbo's slot AND
// sets the handle(s) here; Named (MGPipeFramebufferTarget::Named) writes the record and
// touches nothing here at all - that is the whole of the fourth target's meaning.
Array<MGPipeHandle, kMGPipeFramebufferBindingCount> BoundFramebuffer{};
// ONE SERIAL FOR THE FAMILY, and it moves on EVERY write - a Named record's included,
// because a twin memoising "the framebuffer state I have seen" has to hear about a
// named framebuffer's attachments exactly as it hears about a bound one's. It is the
// number that retires the four g_fboSynced* arrays and the twin's {slot version, object
// version, backend id generation} triple.
Uint64 FramebufferSerial = 0;
// Every FramebufferRecordFor() that found a record at the slot whose GENERATION had
// moved on. It is NOT RefusedObjectCalls: this is a READ by the server's own sync path
// and not a call this applier refused, and set_framebuffer_state's counter contract
// (below) is that no framebuffer call ever moves that one. A non-zero value here is a
// seam defect - an emitter minted a successor for a recycled slot and never described
// it, or handed out a handle it had already retired - so it is counted AND logged, and
// a unit case reads it in every build for the reason the other counters exist.
//
// `mutable` because the three accessors below are const: package E holds the applier
// through a `const auto&` and must keep doing so.
mutable Uint64 StaleFramebufferRecordLookups = 0;
// The three kVarTail unit sets, as received. NO STAGE DIMENSION: MobileGL's
// texture-unit space is one merged array of 192, the same unit may be sampled from two
// stages, and stage is derived server-side from the reflection archive only where the
// target API needs it.
//
// THE VAR-TAIL WINDOW IS THE BOUND AND ENTRIES OUTSIDE IT ARE NOT CLEARED - the
// record is "the last set as received", exactly as set_vertex_buffers is, and
// Start + Count above the bound is Fatal{ProtocolCorruption}.
Array<MGPBoundView, kMGPipeMaxTextureUnits> BoundSamplerViews{};
Uint32 SamplerViewStart = 0;
Uint32 SamplerViewCount = 0;
Uint64 SamplerViewsSerial = 0;
Array<MGPipeHandle, kMGPipeMaxTextureUnits> BoundSamplerStates{};
Uint32 SamplerStateStart = 0;
Uint32 SamplerStateCount = 0;
Uint64 SamplerStatesSerial = 0;
Array<MGPImageView, kMGPipeMaxImageUnits> BoundShaderImages{};
Uint32 ShaderImageStart = 0;
Uint32 ShaderImageCount = 0;
Uint64 ShaderImagesSerial = 0;
// set_draw_program / set_dispatch_program are two calls because the frontend has two
// joins and two PipeInputs slots; bind_shader_state is the third, and a null handle is
// legal in all three and means "nothing bound".
MGPipeHandle DrawProgram = kMGPipeNullHandle;
MGPipeHandle DispatchProgram = kMGPipeNullHandle;
MGPipeHandle BoundShaderCso = kMGPipeNullHandle;
Uint64 ProgramBindingSerial = 0;
// ---- THE THREE FRAMEBUFFER ACCESSORS (ID-19(b)/(d)). They are functions rather than
// members because the storage moved under them and their callers must not have to know
// it did: `DrawFramebuffer()` / `ReadFramebuffer()` answer the question the two members
// used to answer - "which record describes the framebuffer bound to this binding" - by
// resolving BoundFramebuffer[t] through FramebufferRecords.
//
// NULL IS A REAL ANSWER AND HAS EXACTLY THREE CAUSES: nothing is bound to that binding
// (the null handle, which is what a make-current leaves and is NOT an error), no record
// has been written at that slot, or the slot's generation has moved on under the handle
// (which IS an error and is counted and logged - see StaleFramebufferRecordLookups). A
// caller that used to test `MGPipeHandleIsNull(st.DrawFramebuffer.Fbo)` tests the
// pointer instead; the two are the same question.
//
// Defined in PipeApply.cpp rather than inline HERE so this header keeps its include
// closure: the stale-generation path logs, and MG_Util/Debug/Log.h is not in this
// header's closure and may not become part of it.
const MGPFramebufferState* FramebufferRecordFor(MGPipeHandle fbo) const;
const MGPFramebufferState* DrawFramebuffer() const;
const MGPFramebufferState* ReadFramebuffer() const;
};
// The monolith's single applier. Under split there is one per served context.
@@ -288,6 +695,30 @@ namespace MobileGL::MG_Pipe {
// vertex-input serials rather than zeroing them. It does NOT drop the resource or
// vertex-elements records: those describe share-group objects that the switch does not
// destroy, and dropping them is a dropped write on the far side of it.
//
// P4a EXTENDS BOTH HALVES AND THE RULE IS UNCHANGED (D-J4). Cleared: the two framebuffer
// BINDINGS, the three unit sets, DrawProgram / DispatchProgram / BoundShaderCso - all of it
// per-context working state - with their serials ADVANCED and never zeroed. Not cleared:
// texture and renderbuffer resources, sampler CSOs, sampler views, shader CSOs, the
// framebuffer RECORDS, and the texture params and pending uploads that ride on a resource
// record, because a texture lives in a share group exactly as a buffer does.
//
// ID-19(b) MOVED THE FRAMEBUFFER RECORD ACROSS THAT LINE and the reason is worth stating.
// Before it, the whole framebuffer state was working state and a make-current took it. Now
// the RECORD is an object record and only the two BOUND HANDLES are working state, so a
// switch clears the bindings - after which DrawFramebuffer() / ReadFramebuffer() answer
// null, exactly as the cleared records used to answer a null Fbo - and leaves the table
// standing. Dropping the table instead would silently lose the record of every framebuffer
// that is described by NAME and never bound, because the client's re-emission on a fresh
// context is driven by MGPipeSetHashSuppressor::InvalidateAll, which re-sends the two bound
// records and nothing else.
//
// AND THEREFORE NO P4a TRACKER NEEDS A RE-PUBLICATION PATH ON FreshlyPrimed, AND NONE MAY
// HAVE ONE: re-emitting create_sampler_state for a record the applier still holds would
// move its Serial for nothing. What DOES reset on a fresh context is each emitter's
// BOUND-HANDLE latch - the framebuffer and unit-set hashes through
// MGPipeSetHashSuppressor::InvalidateAll, and the program emitter's BoundShaderCso mirror -
// because those mirror working state this function just cleared.
void MGPipeApplierReset();
// THE OTHER SCOPE: the served context is going away and its applier with it, so the object
@@ -344,18 +775,127 @@ namespace MobileGL::MG_Pipe {
// the backend and the gates compile against, and the records above are what they write
// into; the bodies land in the two commits that follow this one on the same branch.
// The scope of one resource_respecify, and it is an APPLIER-SIDE ARGUMENT and not a wire
// record: it is not in PipeFields.def, it crosses no payload, and the transport reads the
// scope off the call it is replaying rather than off a field. The two members mirror
// MGPipeResourceRecord::PendingUpload's key exactly, which is the only thing the applier
// does with them - so UploadTarget is MGPSubData::Target VERBATIM, the whole packed field
// (ID-12: low byte = MGPipeResourceTarget, high byte = the cube-face upload target), the
// same value the emission of that level put in the record. A per-face respecify therefore
// drops the face it redefines and leaves the other five standing, and a caller that packs
// the pair differently here than it packs it there simply matches nothing.
// A NAMED LEVEL IS DROPPED EVEN WHEN EVERY STORAGE-DEFINING FIELD IS UNCHANGED (P4a final
// review C-1): the pointer is the caller's statement that it reallocated that level, and
// a non-base level's extent is not in the descriptor. Only a NULL level with unchanged
// fields is the metadata update that drops nothing (ID-18 M4); the client's mask republish
// is the one caller of that shape and passes null on purpose.
struct MGPRespecifiedLevel {
Uint16 UploadTarget = 0;
Uint16 Level = 0;
};
// THE THREE ACCEPTANCE RETURNS, AND WHY ALL THREE (ID-18 M3, clientfb review M3). D-D5
// step 1 says the client clears a level's dirty flags "for the levels whose record the
// applier ACCEPTED", and the emitter cannot answer that for itself: an `if constexpr` that
// discarded the call, a dead or stale handle (a counted no-op) and a corrupt record (a Fatal
// that deliberately moves no counter) are all invisible from the call site, so a client that
// clears on the strength of having EMITTED drops those texels for good. resource_subdata
// returns it, and so must the two calls that DEFINE the storage a subsequent upload lands
// in - a create or a respecify the applier refused leaves no record for the upload to
// accumulate onto, and B's own bookkeeping (its per-entry descriptor dedupe, its drain list)
// must not advance past a call that never landed.
//
// ALL THREE ARE SOURCE-COMPATIBLE: a Bool return is ignorable, P3a's call sites in
// MG_Impl/Pipe/PipeFill.cpp discard it, and gen_pipe.py never parses this header - the wire
// path calls no MGPipeApply* at all (wire review W1), so PipeCalls.def and
// MobileGL/MG_Pipe/generated do not move.
// resource_create: mints the record and marks the slot Live. Emitted from the buffer
// object's CONSTRUCTOR, so a resource exists before anything can name it; storage is
// defined lazily by the first respecify and a backend tolerates a resource with none.
void MGPipeApplyResourceCreate(const MGPResourceDesc& desc);
//
// Returns true when the record was minted. False for the three refusals: the reserved slot
// 0, a descriptor whose target names no resource kind, and a slot at or above
// kMGPipeMaxResourceSlots.
Bool MGPipeApplyResourceCreate(const MGPResourceDesc& desc);
// resource_respecify: replaces the stored descriptor and bumps Serial. `initialBytes` is
// the shadow when desc.HasDefinedContent, else null. kNeedsAck on the call,
// MGPipeResourceRespecifyNeedsAck(desc) per record - only an immutable store acks.
void MGPipeApplyResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes);
//
// P4a: `level` IS THE SCOPE OF THE REDEFINITION, and MGPResourceDesc cannot carry it - the
// descriptor describes the resource, and a mutable texture redefines its levels ONE
// glTexImage*D AT A TIME. Null means "this respecify redefines the WHOLE resource" - every
// glBufferData / glBufferStorage, every glTexStorage*, every texture view - and drops every
// pending upload, which is right because every level's coordinate system has just been
// replaced. Non-null names the single (uploadTarget, level) the call redefines and drops
// ONLY that key: the frontend's AllocateStorage / MarkStorageDirty are per
// (uploadTarget, level) as well (MG_State/GLState/TextureState/TextureObject.h), so a
// glTexImage2D(level 1) re-marks level 1 AND NOTHING ELSE, while the levels already
// emitted had their client dirty flags cleared at THEIR emission (D-D5 step 1) and nothing
// anywhere still owes them. Clearing the whole set here would lose exactly those texels,
// silently, in every build - the loss the server-side set exists to prevent.
//
// Trailing and defaulted for W1's reason: P3a's buffer call site (PipeFill.cpp:691) and
// every existing case compile unchanged. PACKAGE B PASSES THE PAIR IT JUST ALLOCATED at
// every per-level respecify; it has both halves in hand at the AllocateStorage call site.
//
// A METADATA RESPECIFY IS A RESPECIFY THAT REDEFINES NO STORAGE (ID-18 M4). A sticky
// BindMask / ImageBindableHint bit reaches the applier only on a respecify, and an
// IMMUTABLE texture has no further one - that is what immutable means - so the canonical
// order (glTexStorage2D, then glBindImageTexture or an FBO attachment) would leave the
// record's hint at 0 for ever, and the hint is the PREVENTION half of the texture-remint
// stall class. So B re-emits the descriptor when the mask moves, and a record whose
// STORAGE-DEFINING fields all equal the stored descriptor's is applied as a metadata
// update:
//
// - the descriptor is replaced, so BindMask and ImageBindableHint take their new values;
// - NO pending upload is dropped, whatever `level` says. This REFINES the rule above
// rather than contradicting it: that rule drops the uploads against the storage a
// respecify REPLACES, and a call that replaces no storage replaces no coordinate system
// either, so there is nothing to drop. A mask change arriving between a
// glTexSubImage2D and the sync that consumes it must not eat the texels;
// - the serial advances, which is the whole publication - the twin re-derives its storage
// flags from the new mask at its next sync and recreates only where the backend needs
// it (D's side);
// - and MGPipeResourceRespecifyNeedsAck is false for it BY CONSTRUCTION, because a buffer
// is never classified this way (see the body: glBufferData at an unchanged size is a
// real orphaning reallocation, and glBufferStorage is the one entry point allowed a
// synchronous ack).
//
// Returns true when the descriptor was stored - metadata updates included, since the record
// did move - and false when the call was refused: a descriptor whose target names no
// resource kind, or a handle this applier has no live record for at that generation.
Bool MGPipeApplyResourceRespecify(const MGPResourceDesc& desc, const void* initialBytes,
const MGPRespecifiedLevel* level = nullptr);
// resource_subdata, buffer half: the destination range rides in the record's box through
// MGPipeSetSubDataBufferRange, and a false from that helper is where the EMITTER split.
// The applier stores nothing per record - contents are the backend's - and bumps Serial.
void MGPipeApplyResourceSubData(const MGPSubData& record, const void* bytes);
//
// P4a: `regions` IS THE CALL'S VARIABLE TAIL - MGPSubRegion[record.RegionCount] - and it is
// a trailing DEFAULTED parameter rather than a second entry point. The call has carried
// kVarTail since P2 (PipeCalls.def) and the texture half cannot be applied without it: the
// applier's pending-upload set is (UnionBox, RegionCount, Regions[]) and the verify lane's
// retain mode compares all three. The buffer half declares no regions, so P3a's one call
// site and every existing case are unchanged by the default.
//
// THE RETURN IS THE ACCEPTANCE SIGNAL D-D5 STEP 1 NAMES: true when the record was stored -
// the buffer half landed its range, or the texture half accumulated the shape onto the
// record - and false when it was refused. THE EMITTER MUST GATE ITS DIRTY-FLAG CLEAR ON IT
// ("only for levels whose record the applier ACCEPTED"), because the two refusal paths are
// otherwise invisible to it: a dead or stale handle is a counted no-op and a corrupt record
// is a Fatal that does NOT move RefusedResourceCalls, so in a shipped push build a refused
// upload and an accumulated one are indistinguishable from the call site. A client that
// clears on the strength of having emitted drops those texels for good.
//
// THE RESOURCE-TARGET HALF OF record.Target PICKS THE HALF. MGPSubData::Target is PACKED
// (ID-12): low byte = MGPipeResourceTarget, high byte = the cube-face upload target. The
// buffer half is the whole field being 0 - the encoding the emitter is held to, since a
// buffer has no upload target - and the texture half additionally requires the low byte to
// name a TEXTURE target: Buffer, Renderbuffer and anything at or above
// MGPipeResourceTarget::Count are Fatal{ProtocolCorruption} rather than an upload onto
// whatever object holds that slot in the texture slot space.
Bool MGPipeApplyResourceSubData(const MGPSubData& record, const void* bytes,
const MGPSubRegion* regions = nullptr);
// buffer_subdata_resident: same shape; `bytes` is the application's staging store and is
// valid for the duration of the call only. The op-table entry may be null.
void MGPipeApplyBufferSubDataResident(const MGPSubData& record, const void* bytes);
@@ -403,6 +943,130 @@ namespace MobileGL::MG_Pipe {
// configuration. Bumps IndexBufferSerial.
void MGPipeApplySetIndexBuffer(const MGPIndexBuffer& record);
// ---------------------------------------------------------------------------------
// P4a: the fifteen object and working-state entry points (D-A1, D-B1, D-J1)
// ---------------------------------------------------------------------------------
//
// NOT ONE OF THEM DISPATCHES TO A BACKEND FUNCTION POINTER, and that is the single most
// important structural decision in P4a rather than an omission. Nothing in these families
// reaches the backend at GL-call time today - texture storage only marks a level dirty and
// Espryt allocates lazily at sync, texture params run from SyncTextureObjectToBackend at
// draw sync, renderbuffer storage is allocated inside SyncToBackend on a four-field cache,
// a sampler twin is created lazily from the program pass, and the framebuffer, unit sets
// and program are all resolved at PrepareForDraw. So every call below is either an OBJECT
// RECORD the applier stores or WORKING STATE the applier stores, and Espryt reads the
// applier at the sync points it already has, keyed on a server-owned Serial instead of a
// frontend version. MGPipeResourceOps is therefore UNCHANGED - nine members, same
// signatures - and P4a adds no backend op table and no op-table member at all.
//
// The consequence for the four resource entry points above: they BRANCH on
// record.Desc.Target. A buffer target dispatches into MGPipeResourceOps exactly as P3a
// wrote it; every other target stores and returns. The branch is one comparison against
// kMGPipeResourceTargetBuffer and it is where a mis-typed descriptor becomes visible.
//
// AT THE CONTRACT COMMIT EVERY BODY BELOW IS A STUB, exactly as P3a's nine were: the
// signatures are what the client, the backend and the gates compile against and the
// records above are what they write into; the bodies land in the three commits that
// follow this one on the same branch.
// set_framebuffer_state. Fully resolved - nothing in the record requires a lookup on the
// far side. ContentHash covers every field including Fbo and DrawBuffers[8], which is what
// makes a suppressed record provably mean "the draw-buffer array did not move" and
// therefore "the fragColor broadcast count did not move".
//
// `state.Target` NOW SAYS TWO THINGS AT ONCE (ID-19(b)), and the record always does the
// first of them:
//
// - THE RECORD IS ALWAYS WRITTEN, at FramebufferRecords[state.Fbo.Slot], whatever the
// target is. The table is keyed by the framebuffer HANDLE, so one framebuffer's record
// can never displace another's, and a slot whose object has been recycled is simply
// overwritten by its successor's record (D-I2: no wire lifetime, so nothing to retire).
// - Draw / Read / Both ADDITIONALLY set BoundFramebuffer[Draw] / [Read] / both.
// MGPipeFramebufferTarget::Named sets NEITHER: it is how a DSA entry point hands Espryt
// a framebuffer it is about to blit into or clear WITHOUT claiming it is bound.
//
// FramebufferSerial advances on every applied record, Named included.
//
// Two refusals, both Fatal{ProtocolCorruption} and neither counted (see RefusedObjectCalls:
// this entry point resolves nothing and can only ever fault): a target above Named, a
// draw-buffer entry outside the record's own Color[], a slot at or above
// kMGPipeMaxFramebufferSlots, and the NULL HANDLE - a record that named {0,0} would install
// itself where "nothing is bound" is read, and every emitter has a handle for every
// framebuffer it describes (kMGPipeDefaultFramebuffer {0,1} for the default one).
void MGPipeApplySetFramebufferState(const MGPFramebufferState& state);
// create_sampler_state. `parameters` is the client's canonical SamplerParameters copy,
// beside the record for the one Blob rule's reason; the applier stores it by value.
void MGPipeApplyCreateSamplerState(const MGPSamplerDesc& desc, const SamplerParameters* parameters);
// delete_sampler_state: emitted by the CSO cache's LRU eviction and by the frontend
// sampler object's death helper. Clears Live and drops the record; the client frees the
// slot afterwards.
void MGPipeApplyDeleteSamplerState(const MGPHandleOnly& handle);
// create_sampler_view. Re-issued on the SAME handle whenever the view restrictions move,
// which is legal because Gen increments only on slot reuse and never on a respecify.
void MGPipeApplyCreateSamplerView(const MGPSamplerView& view);
void MGPipeApplyDeleteSamplerView(const MGPHandleOnly& handle);
// set_texture_params: addressed by RESOURCE and independent of any binding, which is what
// lets a texture that is only an attachment, only an image-unit binding or only a
// glCopyImageSubData endpoint carry its parameters at all. params.BuiltinSampler may never
// be the null handle - every ITextureObject owns a sampler object - so a null is
// Fatal{ProtocolCorruption} rather than "no sampler".
//
// Returns true when the record took the parameters (P4a final review m-1, audit F-7): the
// emitter's version latch advances on this answer and on nothing else, the way the
// sub-data and respecify paths latch on theirs, so a refused record - no consumer, no
// record for the handle, a null sampler - is re-sent at the next call rather than at the
// next glTexParameter*. Source-compatible for the same reason the three resource returns
// are: a Bool is ignorable and gen_pipe never parses this header.
Bool MGPipeApplySetTextureParams(const MGPTextureParams& params);
// set_sampler_views / bind_sampler_states / set_shader_images: `tail` is hdr.Count entries
// starting at hdr.Start, and hdr.Start + hdr.Count above the unit bound is
// Fatal{ProtocolCorruption}. Entries outside the declared window are NOT cleared.
void MGPipeApplySetSamplerViews(const MGPSamplerViews& hdr, const MGPBoundView* tail);
void MGPipeApplyBindSamplerStates(const MGPSamplerStates& hdr, const MGPipeHandle* tail);
void MGPipeApplySetShaderImages(const MGPShaderImages& hdr, const MGPImageView* tail);
// create_shader_state. THE ARTEFACTS TRAVEL BESIDE THE RECORD, by pointer: all seven of
// desc.Spirv[] and desc.Reflection are declared with Size 0 ("this record does not declare
// its blob"), which is what a monolith emission is, and the codec is NOT called - zero
// serialisation cost on the monolith path. A verify build serialises, deserialises and
// field-compares before storing, and a mismatch is Fatal{PipeVerifyDiffer, "program-archive"}.
// Splitting this record for a transport whose ring caps one record at half its capacity is
// P5's problem, not this entry point's.
void MGPipeApplyCreateShaderState(const MGPProgramDesc& desc,
const MG_State::GLState::LinkArtifacts* link,
const MG_State::GLState::SpirvArtifacts* spirv);
void MGPipeApplyBindShaderState(const MGPHandleOnly& handle);
void MGPipeApplyDeleteShaderState(const MGPHandleOnly& handle);
void MGPipeApplySetDrawProgram(const MGPHandleOnly& handle);
void MGPipeApplySetDispatchProgram(const MGPHandleOnly& handle);
// set_global_constants: the DEFAULT UNIFORM BLOCK only. Keyed (ShaderCso, Version) and
// emitted at most once per program per frame; `bytes` is MapUBO()'s image, GetUBOSize()
// long, handed over as a companion pointer with Blob.Size 0. record.Version is
// GetUBOContentVersion() and may never be ~0u, which is the backends' "never uploaded"
// sentinel.
void MGPipeApplySetGlobalConstants(const MGPGlobalConstants& record, const void* bytes);
// ---------------------------------------------------------------------------------
// P4a: the named, greppable unmigrated emulations (D-M)
// ---------------------------------------------------------------------------------
//
// ROADMAP.md's P4a row ends "emulation 在 split 下显式 Fatal 直到 P8". In monolith the
// code paths keep running exactly as today - the Fatal is a SPLIT-only arm - so this costs
// P4a a named call site per unmigrated emulation and nothing else. P5/P8 give it teeth: a
// split server that reaches one of these has no client address space to read and must
// abort loudly rather than degrade silently.
//
// Monolith body: (void)name;. The list of names is pinned by
// PipeCatalogueTest.EveryUnmigratedEmulationIsNamedOnce and the call count is grepped by
// the purity gate, so a site that quietly disappears is a red gate rather than a surprise
// at P8.
void MGPipeUnmigratedEmulation(const char* name);
// ---------------------------------------------------------------------------------
// The derivation step (ARCHITECTURE.md 5.3, P2 brief D5)
// ---------------------------------------------------------------------------------
+33 -2
View File
@@ -23,6 +23,14 @@
// (MGPipeTypes.h) is what says so, which is why the same call still carries
// every glBufferData without acknowledging one.
//
// kHasBlob MEANS "THE PAYLOAD OWNS AN MGPBlobRef MEMBER", nothing weaker (P5
// R-13.1). It is not "this call carries bytes": three calls carry bytes with no
// MGPBlobRef at all and they are named in CONTRACT-P5.md table 1, not flagged
// here, because a decoder that trusts kHasBlob has to find a member to read.
// The flags are EXPORTED, once, as kMGPipeCallFlags[] in generated/PipeWire.inc
// (P5 R-13.4): before that table existed every consumer hard-coded its own copy,
// which is how the two carriers below came to disagree with their payloads.
//
// 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
// call is APPENDED to its group; a retired call keeps its slot with a comment. The wire
@@ -77,8 +85,20 @@
// clang-format off
#define MGP_CALL_LIST(X) \
/* ---- screen: caps, resources, persistent map, fences (plan 4.4.1) ---- */ \
X(GetCaps, MGPCaps, kScreen, kReplySlot) \
/* kHasBlob because MGPCaps owns TWO MGPBlobRef members - FormatCapabilities and */ \
/* RendererInfo (MGPipeTypes.h). It carried none until P5 R-13.1; the flag is a */ \
/* property of the payload, so its absence was simply wrong. Adding a flag does not */ \
/* move an opcode: the opcode is this line's 1-based position. */ \
X(GetCaps, MGPCaps, kScreen, kReplySlot|kHasBlob) \
X(ResourceCreate, MGPResourceDesc, kScreen, kNone) \
/* NO kHasBlob, BY RULING (P5 R-13.3; CONTRACT-P5.md table 1 row 19). MGPResourceDesc */ \
/* owns no MGPBlobRef and gains none: under split `initialBytes` is always nullptr and */ \
/* the initial content of a glBufferData(size, data) / glTexImage*(..., data) arrives */ \
/* as resource_subdata records IMMEDIATELY AFTER this one. Reuses a path that is */ \
/* already chunked (MGPipeForEachSubDataRecordRange) and already acceptance-gated; the */ \
/* cost is one extra record. NOTE the SECOND uncarried companion on this call, the */ \
/* MGPRespecifiedLevel* scope (PipeApply.h:792) - it is NOT bytes and NOT covered by */ \
/* this ruling; table 1 row 19 gives it the two pad fields of MGPResourceDesc. */ \
X(ResourceRespecify, MGPResourceDesc, kScreen, kNeedsAck) \
X(ResourceDestroy, MGPHandleOnly, kScreen, kNone) \
X(MapPersistent, MGPHandleOnly, kScreen, kReplySlot|kOptional) \
@@ -101,7 +121,11 @@
X(CreateVertexElements, MGPVertexElements, kCtxCso, kHasBlob) \
X(BindVertexElements, MGPHandleOnly, kCtxCso, kNone) \
X(DeleteVertexElements, MGPHandleOnly, kCtxCso, kNone) \
X(CreateSamplerState, MGPSamplerDesc, kCtxCso, kNone) \
/* kHasBlob because MGPSamplerDesc owns an MGPBlobRef member, `Parameters` - the */ \
/* SamplerParameters POD byte for byte, borderColorForm included (P5 R-13.1). The */ \
/* companion pointer beside the record today is a TYPED frontend pointer */ \
/* (const SamplerParameters*), which is exactly what split may not carry. */ \
X(CreateSamplerState, MGPSamplerDesc, kCtxCso, kHasBlob) \
X(DeleteSamplerState, MGPHandleOnly, kCtxCso, kNone) \
X(CreateSamplerView, MGPSamplerView, kCtxCso, kNone) \
X(DeleteSamplerView, MGPHandleOnly, kCtxCso, kNone) \
@@ -134,6 +158,13 @@
X(ResourceSubData, MGPSubData, kCtxObject, kHasBlob|kVarTail) \
X(BufferSubDataResident, MGPSubData, kCtxObject, kHasBlob|kOptional) \
X(ResourceSubDataComplete, MGPSubDataComplete, kCtxObject, kNone) \
/* NO kHasBlob AND NO MGPBlobRef, BY RULING (P5 R-13.2; CONTRACT-P5.md table 1 row 21). */ \
/* It takes a content pointer today, but under split it CARRIES NO BYTES AT ALL: it is */ \
/* a {range, AccessFlags} control record, and the bytes of [Offset, Offset+Size) arrive */ \
/* AHEAD of it as resource_subdata records covering exactly that range. The ladder this */ \
/* drives rewrites its range "from the authoritative shadow" (Managers.cpp:1047-1076), */ \
/* and under split the authoritative shadow is server-owned (R-11), so subdata is the */ \
/* only way bytes reach it - a blobref here would be a SECOND way to say the same thing. */ \
X(ResourceFlushRange, MGPFlushRange, kCtxObject, kNone) \
X(ResourceReadback, MGPReadback, kCtxObject, kReplySlot) \
X(ResourceCopyRegion, MGPCopyRegion, kCtxObject, kNone) \
+35 -6
View File
@@ -41,10 +41,16 @@
#define MGP_FIELDS_MGPCaps(F) \
F(Dynamic) F(CallMask) F(FormatCapabilities) F(RendererInfo)
// The three respecify-scope fields are listed like any other member: they occupy what used to be
// Pad0's high byte and Pad1, and the generator's cover check only excludes members actually
// NAMED Pad*, so leaving them out would fail --check rather than pass it. They are also real
// G4 comparands - a verify build that let the scope differ silently would be blind to the one
// thing that decides whether a respecify drops one pending upload or all of them.
#define MGP_FIELDS_MGPResourceDesc(F) \
F(Resource) F(Target) F(StorageKind) F(BindMask) F(InternalFormat) F(Width) F(Height) F(Depth) \
F(ArrayLayers) F(Levels) F(Samples) F(FixedSampleLocations) F(Immutable) F(Usage) F(StorageFlags) \
F(HasDefinedContent) F(ImageBindableHint) F(GlNameForDiag) F(ViewOf) F(BufferForTexBuffer) \
F(HasDefinedContent) F(ImageBindableHint) F(HasRespecifiedLevel) F(GlNameForDiag) \
F(RespecifiedUploadTarget) F(RespecifiedLevel) F(ViewOf) F(BufferForTexBuffer) \
F(BufOffset) F(BufSize)
#define MGP_FIELDS_MGPFenceWait(F) \
@@ -78,20 +84,30 @@
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)
@@ -254,6 +270,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)
@@ -326,7 +354,8 @@
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(RenderStateParameters) P(PixelStoreParameters) P(SamplerParameters) P(PerBufferBlendState) \
P(StencilFaceState) \
P(DynamicBackendParameters) P(MGHostSpan) \
P(MGPVertexAttribWire) P(MGPVertexBindingPointWire)
+265 -1
View File
@@ -56,7 +56,11 @@ namespace MobileGL::MG_Pipe {
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.
// 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,
@@ -94,6 +98,15 @@ 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 {
@@ -140,6 +153,66 @@ namespace MobileGL::MG_Pipe {
// 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);
@@ -151,6 +224,197 @@ namespace MobileGL::MG_Pipe {
// 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)
+17 -7
View File
@@ -308,8 +308,13 @@ enum class MGPipeFieldEmitter : Uint8 {
BindRenderState,
BindVertexElements,
CreateRenderState,
SetDispatchProgram,
SetDrawProgram,
SetDynamicState,
SetFramebufferState,
SetPatchState,
SetSamplerViews,
SetShaderImages,
SetVertexAttribDefaults,
};
@@ -318,8 +323,13 @@ inline constexpr const char* kMGPipeFieldEmitterNames[] = {
"BindRenderState",
"BindVertexElements",
"CreateRenderState",
"SetDispatchProgram",
"SetDrawProgram",
"SetDynamicState",
"SetFramebufferState",
"SetPatchState",
"SetSamplerViews",
"SetShaderImages",
"SetVertexAttribDefaults",
};
@@ -344,11 +354,11 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount
MGPipeFieldEmitter::CreateRenderState, // GetDepthFunc
MGPipeFieldEmitter::CreateRenderState, // GetDepthMask
MGPipeFieldEmitter::SetDynamicState, // GetDepthRangeIndexed
MGPipeFieldEmitter::kNone, // GetFramebufferBindingSlot
MGPipeFieldEmitter::kNone, // GetImageTextureBinding
MGPipeFieldEmitter::SetFramebufferState, // GetFramebufferBindingSlot
MGPipeFieldEmitter::SetShaderImages, // GetImageTextureBinding
MGPipeFieldEmitter::SetDynamicState, // GetLineWidth
MGPipeFieldEmitter::CreateRenderState, // GetLogicOp
MGPipeFieldEmitter::kNone, // GetMaxTouchedTextureUnit
MGPipeFieldEmitter::SetSamplerViews, // GetMaxTouchedTextureUnit
MGPipeFieldEmitter::CreateRenderState, // GetMinSampleShadingValue
MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultInnerLevel
MGPipeFieldEmitter::SetPatchState, // GetPatchDefaultOuterLevel
@@ -359,8 +369,8 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount
MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetFactor
MGPipeFieldEmitter::SetDynamicState, // GetPolygonOffsetUnits
MGPipeFieldEmitter::SetDynamicState, // GetPrimitiveRestartIndex
MGPipeFieldEmitter::kNone, // GetProgramForDispatch
MGPipeFieldEmitter::kNone, // GetProgramForDraw
MGPipeFieldEmitter::SetDispatchProgram, // GetProgramForDispatch
MGPipeFieldEmitter::SetDrawProgram, // GetProgramForDraw
MGPipeFieldEmitter::kNone, // GetProgramObject
MGPipeFieldEmitter::CreateRenderState, // GetProvokingVertexMode
MGPipeFieldEmitter::CreateRenderState, // GetRenderStateParameters
@@ -371,7 +381,7 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount
MGPipeFieldEmitter::kNone, // GetTextureBindGeneration
MGPipeFieldEmitter::kNone, // GetTextureContextId
MGPipeFieldEmitter::kNone, // GetTextureObject
MGPipeFieldEmitter::kNone, // GetTextureUnitObject
MGPipeFieldEmitter::SetSamplerViews, // GetTextureUnitObject
MGPipeFieldEmitter::kNone, // GetTransformFeedbackCapturedVertices
MGPipeFieldEmitter::kNone, // GetTransformFeedbackGeneration
MGPipeFieldEmitter::kNone, // GetTransformFeedbackPausedPrimitiveCounter
@@ -388,7 +398,7 @@ inline constexpr MGPipeFieldEmitter kMGPipeFieldEmittedBy[kMGPipeInputFieldCount
MGPipeFieldEmitter::kNone, // GetBoundTransformFeedbackLifetimeId
MGPipeFieldEmitter::kNone, // HasOpenTransformFeedbackSpan
};
inline constexpr SizeT kMGPipeEmittedFieldCount = 34;
inline constexpr SizeT kMGPipeEmittedFieldCount = 40;
struct MGPipeFilledState {
Uint64 CurrentVerbSerial;
+9 -1
View File
@@ -109,6 +109,7 @@ 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);
@@ -247,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 {};
@@ -629,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;
@@ -661,4 +669,4 @@ inline Bool MGPipeVerify(const MGPVertexBindingPointWire& a, const MGPVertexBind
#undef MGP_VERIFY_FIELD
inline constexpr SizeT kMGPipeVerifiedPayloadCount = 71;
inline constexpr SizeT kMGPipeVerifiedPayloadCount = 72;
+121
View File
@@ -118,6 +118,127 @@ enum class MGPWireOp : Uint16 {
kOpCount = 72,
};
// THE FLAGS, EXPORTED ONCE, INDEXED BY OPCODE (P5 R-13.4). MGPWireRecHeader::Flags is
// documented as "MGPipeCallFlags of the call", and until this table existed nothing
// generated said what those were: every consumer that needed to know whether a record owns
// an MGPBlobRef, a variable tail or a reply slot had to hard-code its own copy of
// PipeCalls.def's fourth column, and six of them were about to. A hard-coded copy is how
// GetCaps and CreateSamplerState came to carry an MGPBlobRef member with no kHasBlob on
// their line at all - nothing compared the two, because nothing had both in one place.
//
// Index 0 is MGPWireOp::kInvalid and is kNone: the catalogue is 1-based, and an encoder
// that reads flags for an opcode it never got from the catalogue must see the empty set
// rather than another call's flags.
//
// kHasBlob here means EXACTLY "the payload owns an MGPBlobRef member". Three calls carry
// bytes without one - resource_respecify, resource_flush_range and map_persistent, whose
// companion pointers have no carrier - and they are deliberately NOT flagged; MG_Remote's
// CONTRACT-P5.md table 1 is where those live, because a decoder that trusts kHasBlob has
// to find a member to read.
inline constexpr Uint32 kMGPipeCallFlags[static_cast<SizeT>(MGPWireOp::kOpCount)] = {
/* 0 kInvalid */ static_cast<Uint32>(kNone),
/* 1 GetCaps */ static_cast<Uint32>(kReplySlot | kHasBlob),
/* 2 ResourceCreate */ static_cast<Uint32>(kNone),
/* 3 ResourceRespecify */ static_cast<Uint32>(kNeedsAck),
/* 4 ResourceDestroy */ static_cast<Uint32>(kNone),
/* 5 MapPersistent */ static_cast<Uint32>(kReplySlot | kOptional),
/* 6 UnmapPersistent */ static_cast<Uint32>(kOptional),
/* 7 FenceCreate */ static_cast<Uint32>(kNone),
/* 8 FenceStatus */ static_cast<Uint32>(kReplySlot),
/* 9 FenceWait */ static_cast<Uint32>(kReplySlot),
/* 10 FenceDestroy */ static_cast<Uint32>(kNone),
/* 11 QueryCreate */ static_cast<Uint32>(kNone),
/* 12 QueryBegin */ static_cast<Uint32>(kNone),
/* 13 QueryEnd */ static_cast<Uint32>(kNone),
/* 14 QueryAvailable */ static_cast<Uint32>(kReplySlot),
/* 15 QueryResult */ static_cast<Uint32>(kReplySlot),
/* 16 QueryDestroy */ static_cast<Uint32>(kNone),
/* 17 CreateRenderState */ static_cast<Uint32>(kHasBlob),
/* 18 BindRenderState */ static_cast<Uint32>(kNone),
/* 19 DeleteRenderState */ static_cast<Uint32>(kNone),
/* 20 CreateVertexElements */ static_cast<Uint32>(kHasBlob),
/* 21 BindVertexElements */ static_cast<Uint32>(kNone),
/* 22 DeleteVertexElements */ static_cast<Uint32>(kNone),
/* 23 CreateSamplerState */ static_cast<Uint32>(kHasBlob),
/* 24 DeleteSamplerState */ static_cast<Uint32>(kNone),
/* 25 CreateSamplerView */ static_cast<Uint32>(kNone),
/* 26 DeleteSamplerView */ static_cast<Uint32>(kNone),
/* 27 CreateShaderState */ static_cast<Uint32>(kHasBlob),
/* 28 BindShaderState */ static_cast<Uint32>(kNone),
/* 29 DeleteShaderState */ static_cast<Uint32>(kNone),
/* 30 SetDynamicState */ static_cast<Uint32>(kHasBlob),
/* 31 SetFramebufferState */ static_cast<Uint32>(kNone),
/* 32 SetVertexBuffers */ static_cast<Uint32>(kVarTail),
/* 33 SetIndexBuffer */ static_cast<Uint32>(kNone),
/* 34 SetIndirectBuffers */ static_cast<Uint32>(kNone),
/* 35 SetSamplerViews */ static_cast<Uint32>(kVarTail),
/* 36 BindSamplerStates */ static_cast<Uint32>(kVarTail),
/* 37 SetShaderImages */ static_cast<Uint32>(kVarTail),
/* 38 SetShaderBuffers */ static_cast<Uint32>(kVarTail | kHostSpan),
/* 39 SetStreamOutputTargets */ static_cast<Uint32>(kVarTail),
/* 40 SetGlobalConstants */ static_cast<Uint32>(kHasBlob),
/* 41 SetVertexAttribDefaults */ static_cast<Uint32>(kVarTail),
/* 42 SetPixelPackState */ static_cast<Uint32>(kNone),
/* 43 SetPatchState */ static_cast<Uint32>(kNone),
/* 44 SetDrawProgram */ static_cast<Uint32>(kNone),
/* 45 SetDispatchProgram */ static_cast<Uint32>(kNone),
/* 46 SetResidualValueState */ static_cast<Uint32>(kHasBlob),
/* 47 SetTextureParams */ static_cast<Uint32>(kNone),
/* 48 ResourceSubData */ static_cast<Uint32>(kHasBlob | kVarTail),
/* 49 BufferSubDataResident */ static_cast<Uint32>(kHasBlob | kOptional),
/* 50 ResourceSubDataComplete */ static_cast<Uint32>(kNone),
/* 51 ResourceFlushRange */ static_cast<Uint32>(kNone),
/* 52 ResourceReadback */ static_cast<Uint32>(kReplySlot),
/* 53 ResourceCopyRegion */ static_cast<Uint32>(kNone),
/* 54 GenerateMipmap */ static_cast<Uint32>(kNone),
/* 55 GetTextureImage */ static_cast<Uint32>(kReplySlot),
/* 56 Blit */ static_cast<Uint32>(kNone),
/* 57 Clear */ static_cast<Uint32>(kNone),
/* 58 ReadPixels */ static_cast<Uint32>(kReplySlot),
/* 59 DrawVbo */ static_cast<Uint32>(kHostSpan | kVarTail),
/* 60 LaunchGrid */ static_cast<Uint32>(kNone),
/* 61 MemoryBarrier */ static_cast<Uint32>(kNone),
/* 62 BeginStreamOutput */ static_cast<Uint32>(kNone),
/* 63 EndStreamOutput */ static_cast<Uint32>(kNone),
/* 64 PauseStreamOutput */ static_cast<Uint32>(kNone),
/* 65 ResumeStreamOutput */ static_cast<Uint32>(kNone),
/* 66 Flush */ static_cast<Uint32>(kNone),
/* 67 Present */ static_cast<Uint32>(kNone),
/* 68 SetSwapInterval */ static_cast<Uint32>(kOptional),
/* 69 QueryTimestamp */ static_cast<Uint32>(kReplySlot),
/* 70 QueryCounter */ static_cast<Uint32>(kNone),
/* 71 FenceWaitServer */ static_cast<Uint32>(kNone),
};
static_assert(sizeof(kMGPipeCallFlags) / sizeof(kMGPipeCallFlags[0]) ==
static_cast<SizeT>(MGPWireOp::kOpCount),
"the flags table and the opcode space disagree");
// The only supported read of the table. Out-of-range is kNone rather than undefined
// behaviour, because the one caller that can pass a bad opcode is a decoder holding bytes
// off a stream, and it must reach its own Fatal{ProtocolCorruption} rather than read past
// the array on the way there.
inline constexpr Uint32 MGPipeCallFlagsFor(MGPWireOp op) {
const SizeT index = static_cast<SizeT>(op);
return index < static_cast<SizeT>(MGPWireOp::kOpCount) ? kMGPipeCallFlags[index]
: static_cast<Uint32>(kNone);
}
// Spot checks the generator states about its own output, so that a catalogue edit that
// silently drops a flag is a build break here and not a wrong decode six packages away.
static_assert(MGPipeCallFlagsFor(MGPWireOp::kInvalid) == static_cast<Uint32>(kNone),
"opcode 0 is not a call and carries no flags");
static_assert((MGPipeCallFlagsFor(MGPWireOp::GetCaps) & static_cast<Uint32>(kHasBlob)) != 0,
"MGPCaps owns two MGPBlobRef members; R-13.1 gave the call its flag");
static_assert((MGPipeCallFlagsFor(MGPWireOp::CreateSamplerState) & static_cast<Uint32>(kHasBlob)) != 0,
"MGPSamplerDesc owns an MGPBlobRef member; R-13.1 gave the call its flag");
static_assert((MGPipeCallFlagsFor(MGPWireOp::ResourceFlushRange) & static_cast<Uint32>(kHasBlob)) == 0,
"R-13.2: resource_flush_range carries no bytes on the wire and owns no blobref");
static_assert((MGPipeCallFlagsFor(MGPWireOp::ResourceRespecify) & static_cast<Uint32>(kHasBlob)) == 0,
"R-13.3: initial bytes follow as resource_subdata; MGPResourceDesc owns no blobref");
static_assert((MGPipeCallFlagsFor(MGPWireOp::DrawVbo) &
static_cast<Uint32>(kHostSpan | kVarTail)) == static_cast<Uint32>(kHostSpan | kVarTail),
"draw_vbo is the conditional-tail plus host-span shape the codec is measured on");
struct alignas(8) MGPWireRec_GetCaps {
MGPWireRecHeader Header;
MGPCaps Payload;
+575
View File
@@ -0,0 +1,575 @@
# CONTRACT-P5 — the four tables every P5 package is held to
Authority: this file. `~/w7/notes/p5/BRIEF-P5.md` states the rulings R-1…R-14; this file is
where they become implementable, and where the rulings the brief left to the contract package
are made. Where the two disagree, this file is newer and this file wins — and §6 lists every
place they disagree, so nobody has to diff them.
**How to change it.** `MG_Remote/CONTRACT-P5.md` is c0's file. A package that needs a row
changed goes through the integrator, who edits here first and only then lets the package land.
P4a's contract was corrected seven times and each correction cost a package a rework round;
the point of this file existing at all is that a correction is a one-line diff here rather than
a rediscovery in six places.
It lives beside the code, not in `~/w7/notes/`, because it describes bytes on a wire and has to
move with the branch that defines them.
Base: `feat/disaggregated @ a29807cc`. Every `file:line` below was read at that commit.
---
## §0 The three rules that sit above every table
These are R-2 and R-11 in their formal wording. They apply to every row of table 1 without
restatement, and every reviewer's first three questions (BRIEF §11.1, §11.4) are these three.
**Rule A — a content record must declare its bytes.** Under
`MG_Config::Transport != Monolith`, a record whose payload owns an `MGPBlobRef` and which
carries content must set `Seg` to a real segment, `Offset` to a byte offset **within that
segment**, and `Size` to a **non-zero** byte count. `Blob.Size == 0` on such a record is
`Fatal{ProtocolCorruption}`.
This inverts today's legal state, and deliberately. `MGPipeTypes.h:398-410` says in so many
words that a zero `Blob.Size` means *"this record does not declare its blob"* and *"is not a
fault"* — which is right for monolith, where the bytes travel beside the record as a companion
pointer. Under split there is no beside.
**Rule B — no host pointer crosses.** Under split, `MGHostSpan::Ptr` is written `nullptr` by
the encoder and any non-null `Ptr` seen by the apply side is `Fatal{ProtocolCorruption}`.
Today `Ptr` is the fast path (`MGPipeHostSpan.h:51`), which is exactly why this needs saying.
P5's reduced path should produce **zero** host spans at all — see table 0's cap-bit row.
**Rule C — an applier entry point may not hold a pointer past its return.** A `SEG_STAGE` run
is valid from publish until `retiredSeq` passes the record that named it. The tree has exactly
one violation and it is named rather than tolerated: Espryt's `GLESBufferResource::hostBytes`
(`Managers.h:839`), written by `Ops_H_SubData` (`Managers.cpp:1980-1983`) and `Ops_H_FlushRange`
(`:2035`), read by six later drains (`:2000`, `:2062`, `:2080`, `:2111`, `:2741`, `:2843`).
Under split those two writes must **copy into server-owned storage**. `MOBILEGL_IPC_AUDIT=1`'s
`0xDD` fill over retired staging bytes (R-2.5) is the mechanical control that says whether they
did; without it, an `inproc` implementation that kept the pointer is indistinguishable from one
that copied.
---
## §1 Table 0 — the encoding table
One row per wire field that is **not a handle**. Handles are `{slot, gen}` and are settled by
P2/P3a; everything else that is not a plain scalar is here.
| field | the ruling | zero means | who reads it | evidence / note |
|---|---|---|---|---|
| **segment id space** | `SEG_CMD=1, SEG_STAGE=2, SEG_REPLY=3, SEG_EVENT=4, SEG_SHADOW=5, SEG_ADOPT=6`. Identical values to `Protocol::SegmentKind`. | **"no segment", always.** 0 is never a real segment id. | encoder, decoder, `gMGPipeSegmentResolver` | `protocol.fbs:36-44`; `kMGHostSpanSegNone = 0` at `MGPipeHostSpan.h:21`. The two are tied by `static_assert` in `Wire/PipeWireCodec.cpp`, which is the only place the flatbuffers header and the codec enum meet. `kMGHostSpanSegFromServerIndexMirror = 0xFFFFFFFF` (`:26`) stays reserved for P8. |
| **`MGPBlobRef{Offset, Size, Seg, Pad0}`** (24 B, `MGPipeTypes.h:55-61`) | `Seg` from the row above. `Offset` is a **byte offset inside that segment**, never a host address. `Size != 0` ⇔ "this record declares its blob", and under split a content record **must** declare it (rule A). | `Size == 0` = "no blob declared" — legal in monolith, `Fatal` under split for a content record. | decoder, every bounds cross-check | Today every emitter writes `{Seg=None, Offset=<host address>, Size=0 or real}`. Five of the eleven blob members do declare a real size today (`CsoCache.h:156`, `VertexInputEmit.h:398`, `ResourceTracker.h:216`, `PipeFill.cpp:2259`); the rest write 0. See table 1's "length" column for which. |
| **`MGHostSpan{Ptr, Seg, Pad0, Size, Offset}`** (32 B, `MGPipeHostSpan.h:28-37`) | Split: `Ptr == nullptr` always (rule B). **The 32-byte layout must not be reordered** (`:29-31`). | `Seg == 0` with `Size != 0` is `Fatal`. | decoder | **P5's reduced path must produce none at all.** See the cap-bit row below. |
| **`kCapNeedsHostIndexBytes` (1<<7), `kCapNeedsHostUboBytes` (1<<8)** | **Both are 0 for the whole of P5**, by ruling. | — | client emitters | `MGPipeTypes.h:120-123`. This is the cheapest way to keep every `MGHostSpan` out of the first IPC frame: the two bits are the only things that ask for one. `TriangleScenario` must therefore use a **VBO-backed draw and not client-array indices**, or `kDrawHasUserIndices` (`MGPipeTypes.h:1212`) produces a span the split filling for which is P8's. |
| **`MGPCaps`** (4 members, `MGPipeTypes.h:126-139`) | **One carrier, not two.** `MGPCaps` is the model; `protocol.fbs`'s `CapsSnapshot` is its transport form. `Dynamic` and `CallMask` cross as POD bytes; `FormatCapabilities` and `RendererInfo` cross as the two blobs whose serializers are P5's new work (`MG_Remote/CapsCodec.h`). | — | `CapsMirror` (client), `ServerSession` (server) | `MGPCaps` has only a **compositional** size assertion (`MGPipeTypes.h:145-146`) because `DynamicBackendParameters` still carries `SizeT` and `GLenum`; P0.5's fixed-width rewrite never happened. **P5 does not rewrite it** — see the ABI row. |
| **`CapsSnapshot` redundancy** | `tableSlotMask` (`protocol.fbs:94`) is **DELETED**, not renamed. `maxComputeWorkGroupCount` / `maxComputeWorkGroupSize` (`:92-93`) and `prefersCpuXfbPrimitiveAccounting` (`:95`) are **deleted** too: the first two ride inside `Dynamic` already (`BackendObject.h:392-393`), and the third is answered by `kCapCpuXfbPrimitiveAccounting`. | — | s1 (the schema), c1 (the mirror) | R-8 offered rename-or-delete for `tableSlotMask`; **delete**, for two reasons and the second is decisive. `ARCHITECTURE.md:114` says `CallMask` *replaces* "is this table slot null" as the capability probe, so a field whose comment is "which `GLFunctionsTable` slots the peer registered" re-introduces precisely what it replaced. And `GLFunctionsTable` has **69** function-pointer slots (`BackendObject.h:117-292`), so a `ulong` mask cannot address it and never could — it is five bits short on day one. |
| **`MGPCaps::CallMask` layout** | bits **0..8** = `MGPCapBit`, unchanged. bits **9..31** reserved. bits **32..47** = the **consumer mask**: bit `32+n` means "the server has a consumer for MGPipe subsystem bit `n`". bits **48..63** reserved. | a clear consumer bit = "this server does not consume that family; emit nothing for it". | `CapsMirror::ServerConsumes` — the **only** legal client-side source | c0's ruling, and the thing that makes R-8 implementable at all: R-8 says the client's liveness gates must read the `CallMask` mirror, but `CallMask` as declared has only nine feature bits and no per-family bit. Constants and the two fold/test helpers are in `MG_Remote/CapsCodec.h`; `CapsCodec.cpp` asserts the block does not collide with `MGPCapBit` and that P4a's `0x1fff` fits sixteen bits. |
| **ABI agreement** | `Hello`/`Welcome` assert both peers agree on `sizeof(DynamicBackendParameters)`, `sizeof(MGPCaps)`, `sizeof(GLFunctionsTable)` and `buildFingerprint`. A mismatch is `Fatal{AbiMismatch}` and **never** a downgrade. | — | s1 | The compositional assertion above means the caps block's literal size is ABI-dependent. P6's spawn is same-machine, same-binary and inherits this unchanged. A fixed-width rewrite of `DynamicBackendParameters` is **P7's** account, not P5's. |
| **`MGPSubData::Target`** | Packed: **low byte = `MGPipeResourceTarget`, high byte = the cube-face upload target**. Read only through `MGPipeSubDataResourceTargetOf` / `MGPipeSubDataUploadTargetOf`. Whole field `== 0` is the **buffer** half; a low byte naming `Buffer`, `Renderbuffer` or `>= MGPipeResourceTarget::Count` is `Fatal{ProtocolCorruption}`. | whole field 0 = buffer upload | applier | P4a ID-12. Already settled; copied here because a decoder that open-codes it is the class-1 defect. |
| **`MGPImageView::Access`** | The three-value encoding at `ImageEmit.h:146-159`, verbatim. | — | decoder, applier | **This encoding has only ever lived in a package header** (P4a R-3). This table is its first wire reader, so it is now contract. |
| **`MGPSamplerView::Target`** | The encoding at `SamplerEmit.h:900`, verbatim. | — | decoder, applier | Same status as the row above. |
| **`MGPFramebufferState::DrawBuffers[8]`** | The `-1` / default-token narrowing at `FramebufferEmit.h:146-163`, verbatim. A draw-buffer entry naming a colour attachment outside the record's own array is `Fatal{ProtocolCorruption}` (the applier already says so). | `-1` = "no attachment" | decoder, applier | Same status. |
| **`MGPReplySlot::Id`** | **= the record's sequence number** (R-3). No new id space, no allocator. The server writes the answer into `SEG_REPLY[seq % slots]` and **stamps `seq` back into the slot header** so a wrong-slot read is detectable rather than plausible. | seq is 1-based; `0` = "no record / not encoded" | client barrier wait | `ARCHITECTURE.md:124`: the wire carries no per-record seq field, so seq *is* the ordinal. `MGPReplySlot` exists (`MGPipeTypes.h:78-81`) and **no payload of the ten `kReplySlot` calls contains one** — which is exactly why the id must be derived rather than carried. P9 generalises this to "seq is the id's initial value", which extends the rule rather than overturning it. |
| **reply slot header** | `{Uint64 Seq; Int32 Status; Uint32 Size;}` — 16 bytes, then the payload. `Status`: **0 = OK, 1 = DECLINED, 2 = ERROR**. | — | client | **`DECLINED` is a real answer, not a failure.** It is how `MapPersistent` says `nullptr` (R-6) and how the four `Bool` acceptance entry points say `false` (R-5). A client that treats DECLINED as an error re-creates ID-39's 66 lost uploads from the other side. |
| **`kRecPad` and seq** | A wrap filler **does not advance seq**, on either side. | — | both | R-9. `RingConsumer::Pop` already skips fillers; the rule is stated because the *counter* is the caller's, not `Pop`'s. A side that counts pads drifts by one per wrap, for ever — and since seq is the reply-slot id, a drifted seq reads another call's answer instead of failing. Pinned by `RingTest.AWrapFillerDoesNotAdvanceTheRecordSequence`. |
| **per-opcode flags** | `kMGPipeCallFlags[MGPWireOp::kOpCount]` in `generated/PipeWire.inc`, read only through `MGPipeCallFlagsFor(op)`. Index 0 (`kInvalid`) is `kNone`. | `kNone` = no flags | every package | R-13.4. Before this table existed nothing generated exported the flags, so six packages were each about to hard-code `PipeCalls.def`'s fourth column — which is how `GetCaps` and `CreateSamplerState` came to own an `MGPBlobRef` with no `kHasBlob` on their line. `gen_pipe.py` now also refuses a flag token that is not an `MGPipeCallFlags` enumerator, with two negative controls in `--self-test`. |
| **`kHasBlob`'s meaning** | **Exactly "the payload owns an `MGPBlobRef` member"** — nothing weaker. | — | decoder | `PipeApply.h:78-79` already says so. Three calls carry bytes with **no** `MGPBlobRef`; they are table 1 rows 1921 and are deliberately unflagged, because a decoder that trusts `kHasBlob` has to find a member to read. |
---
## §2 Table 1 — the byte carriers
**23 rows, not 19.** BRIEF §3 lists 19 and `scout-premortem:§3` lists a different 19; the
union is 23 and the four that only the premortem lists — `MapPersistent`, `ResourceReadback`,
`ReadPixels`, `GetTextureImage` — are precisely the ones whose bytes travel **server → client**.
Leaving them out of the byte-carrier table is how a phase discovers in week three that it never
decided where readback pixels land. They are rows 2023 and are marked with their owning phase.
Columns: **flags** · **blob member** · **companion pointer today** · **which segment the bytes
live in** · **who owns that memory** · **when the slot retires** · **who declares the length,
who cross-checks it** · **reply name** (for `kReplySlot` rows).
`apply` = retires when `DecodeAndApply` returns. `submit` = when the server has handed the bytes
to the driver. `gpu` = `completedFrameSerial`.
### Group A — `kHasBlob`, the payload owns an `MGPBlobRef`
| # | call (op) | flags | blob member | companion today | segment | owner | retires | length declared / cross-checked |
|---|---|---|---|---|---|---|---|---|
| 1 | `CreateRenderState` (17) | `kHasBlob` | `Blob` (`MGPipeTypes.h:364`) | `const void* chunkBytes` (`PipeApply.h:740`), passed `CsoCache.h:157` | `SEG_STAGE` | client stages, server copies on apply | **apply** | declared real (`CsoCache.h:156`, `= kMGPipePipelineChunkBytes`); **nothing reads it today**`MGPipeApplyCreateRenderState` (`PipeApply.cpp:1280`) never touches `Blob.Size`. Decoder must cross-check against `ChunkMask`. |
| 2 | `CreateVertexElements` (20) | `kHasBlob` | `Blob` (`:419`) | `const void* blobBytes` (`PipeApply.h:932`), passed `VertexInputEmit.h:399` | `SEG_STAGE` | client stages, server copies | **apply** | declared real (`VertexInputEmit.h:398`); **cross-checked, and this is the model for every other row**: `PipeApply.cpp:1990-1999` recomputes `AttributeCount*sizeof(MGPVertexAttribWire) + BindingPointCount*sizeof(MGPVertexBindingPointWire)` and refuses a disagreement; both counts bounded by `kMGPipeMaxVertexAttribs`. |
| 3 | `CreateShaderState` (27) | `kHasBlob` | **seven**: `Spirv[6]` (`:507`) + `Reflection` (`:508`) | **two typed frontend pointers**`const LinkArtifacts*` + `const SpirvArtifacts*` (`PipeApply.h:1039-1041`), passed `ProgramEmit.h:257` | `SEG_STAGE`, seven independent runs | client stages, server copies | **apply** | all seven declare `Size = 0` today and `Reflection.Offset` is literally `(Uint64)&link` (`ProgramEmit.h:254`). **The serializer already exists and no package may write a second one**: `EncodeProgramArtifacts`/`DecodeProgramArtifacts` (`ProgramArtifactsCodec.{h:53,60,cpp:252,264}`), its own suite, and the verify build already round-trips **every real program it links** (`PinProgramArchiveRoundTrip`, `PipeApply.cpp:1056-1087`, called `:2638`). |
| 4 | `SetDynamicState` (30) | `kHasBlob` | `Blob` (`:389`) | `const void* chunkBytes` (`PipeApply.h:748`), passed `PipeFill.cpp:2260` | `SEG_STAGE` | client stages, server copies | **apply** | declared real (`PipeFill.cpp:2259`); **nothing reads it** (`PipeApply.cpp:1363-1370` scatters by `ChunkMask`). Same fix as row 1. |
| 5 | `SetGlobalConstants` (40) | `kHasBlob` | `Blob` (`:848`) | `const void* bytes` = `MapUBO()`'s image (`PipeApply.h:1052`), passed `ProgramEmit.h:195` | `SEG_STAGE` | client stages, server copies | **apply** | declares **0** (`ProgramEmit.h:194`); cross-check exists at `PipeApply.cpp:2784` against `Desc.GlobalUboSize` but is **inert while Size is 0**. Under rule A it becomes live. Per program per frame, unbounded length — the row most worth watching against R-10's max-record counter. |
| 6 | `SetResidualValueState` (46) | `kHasBlob` | `Blob` (`:932`) | **none, and no record either**: the entry point is `MGPipeApplySetResidualValueState(const ResidualValueBlock&)` (`PipeApply.h:760`), passed `PipeFill.cpp:2184`. **`MGPResidualValueState` is never instantiated on the live path.** | `SEG_STAGE` | client | **apply** | nothing declares it. The encoder must invent **both** the record fill and the blob fill. `sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE == 8` is statically asserted (`MGPipeTypes.h:924-927`) and only ever ratchets **down**. **This is the hardest row in the table** and neither scout flagged it; see §6. |
| 7 | `ResourceSubData` (48) | `kHasBlob\|kVarTail` | `Blob` (`:1009`) | `const void* bytes` + `const MGPSubRegion* regions` (`PipeApply.h:897-899`); buffer half `PipeFill.cpp:705`, texture half `TextureEmit.h:1278-1280` | `SEG_STAGE` | client stages, **server must copy** (rule C names this call) | **apply** | **the two halves disagree today**: buffer declares real (`ResourceTracker.h:216`), cross-checked at `PipeApply.cpp:702`; texture declares **0** (`TextureEmit.h:1265-1267`) on the grounds that the byte count is *"the server's to compute once it has picked box-or-rects"* — which cannot be a bounds check. **Under rule A the texture half must declare too.** Tail: `MGPSubRegion[RegionCount]` (`:1007`). |
| 8 | `BufferSubDataResident` (49) | `kHasBlob\|kOptional` | `Blob` (`:1009`, same payload) | `const void* bytes` = application staging, *"valid for the duration of the call only"* (`PipeApply.h:90`, `:901`); one caller, `PipeFill.cpp:727` | `SEG_STAGE` | client stages, server copies | **apply** | declared real via `MGPipeBuildSubDataRecord`, cross-checked at `PipeApply.cpp:702`. `kOptional` is a **capability** question under split, not a null-pointer question: the client must gate on `kCapResidentSubData` through the caps mirror, never on a table slot (R-8). |
### Group B — `kVarTail`, a tail and no blob member
Every row here declares its tail by a **count**, and `MGP_WIRE_CHECK_BOUNDS` **cannot see the
tail at all** — it only proves `size >= sizeof(MGPWireRec_X)`, so a record declaring
`Count = 4000` while carrying 8 bytes passes today. The decoder must recompute the total from
the declared count(s) and require it to **equal** `MGPWireRecHeader::Size`.
| # | call (op) | flags | tail element × count | companion today | segment | owner | retires | length |
|---|---|---|---|---|---|---|---|---|
| 9 | `SetVertexBuffers` (32) | `kVarTail` | `MGPVertexBuffer` × `Count` (`:730`) | `const MGPVertexBuffer*` (`PipeApply.h:941`), `VertexInputEmit.h:245` | `SEG_STAGE` (tail follows the payload in `SEG_CMD` only if it fits the record bound) | emitter-owned `Vector`, **reused next emission** | **apply** | `Count`; no tail cross-check today |
| 10 | `SetSamplerViews` (35) | `kVarTail` | `MGPBoundView` × `Count` (`:778`) | `const MGPBoundView*` (`PipeApply.h:1028`), `SamplerEmit.h:786` | as above | as above | **apply** | `Count`; `Start+Count` past the unit bound is already `Fatal` (`PipeApply.h:1024-1026`) — a *slot* bound, not a byte-length check |
| 11 | `BindSamplerStates` (36) | `kVarTail` | `MGPipeHandle` × `Count` (`:785`) | `const MGPipeHandle*` (`PipeApply.h:1029`), `SamplerEmit.h:860` | as above | as above | **apply** | as above |
| 12 | `SetShaderImages` (37) | `kVarTail` | `MGPImageView` × `Count` (`:802`) | `const MGPImageView*` (`PipeApply.h:1030`), `ImageEmit.h:129` | as above | as above | **apply** | as above |
| 13 | `SetShaderBuffers` (38) | `kVarTail\|kHostSpan` | **two tails**: `MGPBufferRange` × `Count` (`:826`), then `MGHostSpan` × `HostSpanCount` (`:828`) | **none — no applier entry point exists.** P5 writes the first producer *and* the first consumer. | `SEG_STAGE` | — | **apply** | `HostSpanCount` is 0 **or** `Count`, never anything else (`MGPipeTypes.h:820-823`), so the two arrays stay index-aligned. `kCapNeedsHostUboBytes` is 0 for all of P5, so the second tail is **always absent** in this phase. |
| 14 | `SetStreamOutputTargets` (39) | `kVarTail` | **two tails**: `MGPBufferRange` × `Count`, then `Uint32` × `Count` (`:836-838`) | **none — no applier entry point exists.** | `SEG_STAGE` | — | **apply** | one `Count` sizes both tails; off the reduced path in P5 |
| 15 | `SetVertexAttribDefaults` (41) | `kVarTail` | `MGPAttribValue` × `Count` (`:864`) | `const MGPAttribValue*` (`PipeApply.h:756`), `PipeFill.cpp:2115` | as above | as above | **apply** | **two declarants that must agree**: `Count` and `popcount(Mask)` (`:863`, contract at `PipeApply.h:754-755`). A disagreement is a wire fault nothing checks today; the decoder must. |
| 16 | `DrawVbo` (59) | `kHostSpan\|kVarTail` | `MGPDrawRange` × `NumDraws` (`:1239`), then a **conditional** `MGHostSpan` when `Flags & kDrawHasUserIndices` (`:1212`, `:1231`) | **none — no applier entry point exists.** | `SEG_STAGE` | — | **apply** | `NumDraws`; the span carries its own `Size`. **The only `kHostSpan` on the hot path, and P5 must produce none of them**`TriangleScenario` uses a VBO-backed draw precisely so this tail never appears. `MGPipeTypes.h:1221-1224` defers the fixed-head-versus-tail question to this phase: **P5 keeps it in the tail, unchanged**; there are no per-draw byte histograms yet to justify moving it, and moving it would be a wire-format change with no measurement behind it. |
### Group C — carries content with **no** `MGPBlobRef` and **no** `kHasBlob`
These three are the reason `kHasBlob` had to be given an exact meaning (table 0).
| # | call (op) | flags | ruling | evidence |
|---|---|---|---|---|
| 17 | `CreateSamplerState` (23) | **now `kHasBlob`** (R-13.1) | `MGPSamplerDesc` **does** own an `MGPBlobRef Parameters` (`:429`) and the flag was simply missing. The blob is `memcpy(sizeof(SamplerParameters))` — a POD, and **`borderColorForm` must survive byte for byte** (`MGPipeTypes.h:425-428`), because all three colour representations are always numerically populated and it is the only thing that says which one the backend must use. | companion today is a **typed frontend pointer**, `const SamplerParameters*` (`PipeApply.h:1000`), passed `SamplerEmit.h:458`. Declares `Size = 0` (`SamplerEmit.h:433-435`); cross-check at `PipeApply.cpp:2353` is inert until rule A arms it. **Padding trap:** `SamplerEmit.h:437-445` — assignment leaves three trailing padding bytes stale, and the bytes staged must be the bytes a later `memcmp` compares. |
| 18 | `GetCaps` (1) | **now `kReplySlot\|kHasBlob`** (R-13.1) | `MGPCaps` owns **two** `MGPBlobRef`s, `FormatCapabilities` and `RendererInfo` (`:137-138`), and carried no `kHasBlob` at all. | `PipeCalls.def:80` before the fix. Serializers are P5's new work (`MG_Remote/CapsCodec.h`); the header itself defers them to this phase (`MGPipeTypes.h:134-136`). |
| 19 | `ResourceRespecify` (3) | stays `kNeedsAck`, **no `kHasBlob`** (R-13.3) | **`initialBytes` is always `nullptr` under split. Initial content arrives as `ResourceSubData` records immediately after this one.** `MGPResourceDesc` owns no `MGPBlobRef` and gains none. | The alternative was costed and rejected: `MGPBlobRef` is 24 bytes, `MGPResourceDesc`'s two pads are `Uint16 Pad0` (`:303`) + `Uint32 Pad1` (`:315`) = **6 bytes**, so a blob member takes the struct 88 → 112 and moves `MGP_ASSERT_POD(MGPResourceDesc, 88)` (`:320`). The chosen route reuses a path that is already chunked (`MGPipeForEachSubDataRecordRange`, `PipeFill.cpp:694-713`) and already acceptance-gated; it costs one extra record. `HasDefinedContent` (`:301`) is the field the encoder branches on, and it already exists. **The texture path already does exactly this**`TextureEmit.h:1137` passes `nullptr` and relies on a following upload — so this generalises today's texture behaviour to buffers rather than inventing anything. |
| 19b | `ResourceRespecify`'s **second** uncarried companion | — | **`const MGPRespecifiedLevel* level` (`PipeApply.h:792-795`, 4 bytes: `Uint16 UploadTarget; Uint16 Level;`) has no wire carrier either, and it is not bytes — R-13.3 does not cover it.** Ruling: it rides in `MGPResourceDesc`'s existing pads — `Pad1` (4 B, `:315`) becomes `{Uint16 RespecifiedUploadTarget; Uint16 RespecifiedLevel;}` and one byte of `Pad0` (`:303`) becomes `Uint8 HasRespecifiedLevel`. **Zero size change, `MGP_ASSERT_POD(..., 88)` does not move**, and `PipeFields.def`'s `MGP_FIELDS_MGPResourceDesc` gains the two named members (pads are excluded from field lists, so this is required, not optional). | Null means "this respecify redefines the **whole** resource" and drops every pending upload; non-null names the single `(uploadTarget, level)` and drops **only** that key. Clearing the whole set for a per-level `glTexImage2D` loses exactly the texels the server-side set exists to protect (`PipeApply.h:805-820`). Without a carrier, every OpenRA per-level respecify would silently take the whole-resource arm. **LANDED** (integrator ruling A made `MGPipeTypes.h` c0's file): `Uint8 HasRespecifiedLevel` in Pad0's high byte, `Uint16 RespecifiedUploadTarget; Uint16 RespecifiedLevel;` in Pad1, `MGP_ASSERT_POD(MGPResourceDesc, 88)` unmoved, plus an `offsetof` assertion that the pair stays adjacent and in `MGPRespecifiedLevel`'s order. **Read it only through `MGPipeRespecifyIsWholeResource` / `MGPipeRespecifiedUploadTargetOf` / `MGPipeRespecifiedLevelOf`, and write it only through `MGPipeSetRespecifiedLevel` / `MGPipeClearRespecifiedLevel`**: three fields are one value, and an open-coded reader that forgets the presence byte reads level 0 of upload target 0 as a real scope. **The carrier has no producer** — P5 builds only whole-resource descriptors, and a verify build pins that (`PinWholeResourceRespecifyScope`, `PipeApply.cpp`, the `PinNoLiveHostWrites` shape) so the phase that wires it cannot arrive unannounced. |
| 20 | `ResourceFlushRange` (51) | stays `kNone` (R-13.2) | **It carries no bytes at all under split.** It is a `{range, AccessFlags}` control record; the bytes of `[Offset, Offset+Size)` arrive **ahead of it** as `ResourceSubData` records covering exactly that range. | R-13.2 offered "add a blobref" or "write the convention down". Neither, and for a reason: the ladder this record drives rewrites its range *"from the authoritative shadow"* (`Managers.cpp:1047-1076`), and under split the authoritative shadow is **server-owned** by rule C — so `resource_subdata` is already the only way bytes reach it, and a blobref here would be a second, forgeable way to say the same thing. `AccessFlags` must still cross **verbatim**, not normalised (`PipeApply.h:902-903`). **Overturn condition:** if the tier-1 `INVALIDATE_RANGE` arm turns out to need the bytes and the range in the *same* record — i.e. an intervening record could stale the subdata — this needs its own blobref. It cannot happen while the verb barrier holds, because nothing interleaves; **revisit when the barrier retires for the buffer family.** |
### Group D — the four server → client rows the brief's list omitted
| # | call (op) | flags | ruling | reply name |
|---|---|---|---|---|
| 21 | `MapPersistent` (5) | `kReplySlot\|kOptional` | **Returns `nullptr` under split, always** (R-6/R-2.4). Its `const void* seedBytes` companion (`PipeApply.h:917`) therefore never crosses in P5 and needs no carrier. The three frontend sites already tolerate a decline (`BufferObject.cpp:238`, `:603-606`, `:657-660`). Answer travels as `Status = DECLINED` with a zero-length payload. | `map_persistent.decline` |
| 22 | `ResourceReadback` (52) | `kReplySlot` | Bytes go **server → client** in `SEG_EVENT` via `OnBufferWriteback` (#3), not in the reply slot: the destination is the client's shadow and the size is the resource's, not a fixed slot's. The reply slot carries only completion. **The ordering rule is load-bearing:** the writeback is applied **before** the mutation epoch bumps, never after (`ARCHITECTURE.md:292-294`, `Managers.cpp:2120-2136`). | `resource_readback.done` |
| 23 | `ReadPixels` (58) / `GetTextureImage` (55) | `kReplySlot` | **`ReadPixels` blocks in P5** and its pixels come back in the reply slot, which is why `ReplyPool::SlotBytes()` is sized from the scenario's largest read rather than guessed. `MGPReadbackInfo` has `DstOffset`/`DstSize` but **no `Seg`** (`MGPipeTypes.h:1197-1206`): ruling — the destination is **always `SEG_REPLY`** in P5, so no `Seg` field is added; the PBO destination (fire-and-forget plus a client-side `MarkGpuWritten`) is b1's and also needs none, because a PBO destination is a resource handle rather than a segment. `GetTextureImage` is **not on P5's reduced path** and its slot stays `Fatal{UnmigratedVerb}`. | `read_pixels.pixels` |
---
## §3 Table 2 — `PipeInputs` field ownership
**This section is the SPEC and the four class definitions. The authoritative instance is
generated**: package p1 writes the generator that emits `generated/PipeFieldOwnership.inc` plus
a `--check`, in the shape of `gen_pipe_dirty_surface.py`. **A field in none of the four classes
is a build failure** (R-7.1) — that is the whole mechanism, and a hand-maintained table would
be wrong within a week.
The domain is **63 fields** (`kMGPipeInputFieldCount`, asserted `generated/PipeFilled.inc:96`)
**plus the 7 sticky forwards**, which are among those 63 but are exempted from the poison and
so need their own row. 70 rows, each in exactly one class.
### The four classes
**RECORD-SUPPLIED** — a pushed record supplies the **whole** field, so the server never needs
the client for it.
Membership is `kMGPipeFieldEmittedBy` (`generated/PipeFilled.inc:336-402`,
`kMGPipeEmittedFieldCount = 40`) **minus** the nine for which
`EmittedCallSuppliesTheWholeField` returns false (`MG_Impl/Pipe/PipeFill.cpp:1924-1939`;
reasons `:1875-1923`). **31 fields today.**
The nine excluded, with the generator's own reason: `GetPixelStoreParameters` ("only the PACK
half has a carrier"); `GetCurrentVertexAttribute` ("the applier cannot reproduce GLContext's
cross-view conversion"); `GetMaxTouchedTextureUnit` ("the set is hash-suppressed while the
high-water mark still moves"); and six sharing one reason — "the storage is a frontend heap
reference and the record carries an 8-byte `{slot, gen}`" — `GetBoundVertexArray`,
`GetFramebufferBindingSlot`, `GetImageTextureBinding`, `GetTextureUnitObject`,
`GetProgramForDraw`, `GetProgramForDispatch`.
**APPLIER-DERIVED** — the applier writes it from the records it already applies; no client
participation at all. Today: the render-state mirrors, `m_pixelStore[0]` (the **pack** half),
capability bits, the current vertex attribute, and the patch fields — written at
`PipeApply.cpp:177-184`, `:1338-1437`, plus everything `MGPipeDeriveRenderStateFields`
(`PipeApply.h:1084`, `PipeApply.cpp:2157`) derives.
**BARRIER-PULLED** — **P5's debt, and every row names the phase that retires it.** The server
answers by reading a value the client's residual fill put into the single shared `gPipeInputs`
while the verb barrier holds both threads apart. It is correct only because of that barrier,
which is why the barrier is load-bearing rather than cautious.
Each read increments `PipeStats::CallClass::ResidualPulls` (short name `rsp`, inside the
`#if MOBILEGL_PIPE_PUSH` block so G1 holds), published per frame. **`rsp`'s value at the end of
P5 is the size of the P6/P7/P8 debt** and goes into MEASUREMENTS.
`MOBILEGL_IPC_STRICT_ERRORS=1` promotes every read in this class to `Fatal`, and a named test
asserts the abort actually happens — an instrumentation that cannot go red is decoration.
**FATAL** — no carrier, and the reduced path never reads it, so a read is a real defect.
`Fatal{UnmigratedPipeInput, "<Field>@<verb>"}` (`generated/PipeFilled.inc:407-413`), live at
every log level on purpose (`PipeInputs.h:29-31`: *"this is not `MOBILEGL_ASSERT`, which is
inert in INFO builds"*).
### The known BARRIER-PULLED rows — the 21 the reduced path actually reads
Union of `kClear` (7 of its 18 own fields), `kDraw` (19 of 47) and `kReadback` (12 of 17).
OpenRA adds no field to this set — it widens the **site** set, not the field set, and is the
first thing to reach the read-attachment sites (`Managers.cpp:8603`, `:8966`) and the
`maxTouchedUnit >= 0` texture-unit walks.
| field | class | retires in | note |
|---|---|---|---|
| `GetBoundVertexArray` | O | **P8** | `DirectGLES.cpp:4486`, `PrepareForDraw`, **unconditional on every draw**. `PipeFill.cpp:1902-1905` says the pull retires at P8, not here. |
| `GetProgramForDraw` | O | **P8** (Espryt), P7 (Magma) | `DirectGLES.cpp:4497`, same site, also unconditional. |
| `GetBufferBindingSlot` | O | P8 (indirect half), P9 (readback), P13 (transfer) | 18 Espryt sites; the 7 of 15 `BufferTarget`s no call covers (`Coverage.def:37-70`). |
| `GetBufferBindingPoint` | O | P3b/P4b + P7 | |
| `GetTouchedBufferBindingPointCount` | V | P3b/P4b | |
| `GetFramebufferBindingSlot` | O | P3b/P4b (Espryt), **P7** (Magma) | 8 Espryt sites through `GetFramebufferBindingSlotChecked`; `SyncCurrentFBO` (`:2995`) is self-declared monolith glue (`DirectGLES.cpp:2961-2965`) while `BindCurrentFBO` (`:4303-4353`) is already split-clean. |
| `GetTextureUnitObject` | O | P3b/P4b, P7 | 13 Espryt + 8 Magma sites. |
| `GetImageTextureBinding` | O | P3b/P4b, P7 | |
| `GetActiveTextureUnit` | V | P3b/P4b | server answers from its own state (`Coverage.def:215-219`). |
| `GetMaxTouchedTextureUnit` | V | P3b/P4b | hash-suppressed set, high-water mark still moves. |
| `GetTextureContextId` | V | P3b/P4b | **not a value to migrate**: the server answers from its own `Serial`. `Coverage.def:220-224` is explicit that no call carries these three and none should. |
| `GetTextureBindGeneration` | V | P3b/P4b | as above |
| `GetSamplingResolutionGeneration` | V | P3b/P4b | as above |
| `GetCurrentVertexAttribute` | V | P3b/P4b | the applier cannot reproduce GLContext's cross-view conversion. |
| `GetPixelStoreParameters` | V | **split in P5 — see below** | |
| `IsTransformFeedbackActive`, `IsTransformFeedbackPaused`, `GetTransformFeedbackProgram`, `GetTransformFeedbackGeneration`, `GetBoundTransformFeedbackLifetimeId`, `GetTransformFeedbackCapturedVertices` | V/O | P3b/P4b (Espryt XFB scatter), P7 (Magma) | read on `kDraw`; XFB itself is off the reduced path but these are read on the draw walk regardless. |
**`GetPixelStoreParameters` is split into pack and unpack in this phase** (R-7's one small
migration, and it is c0's ruling to keep rather than soften). The field is `m_pixelStore[2]` and
only `[0]` (pack) has a carrier — which the applier does write (`PipeApply.cpp:1373`) — so the
whole field reads as unmigrated while the half a readback needs is fine. Splitting it is what
stops the readback path from taking a whole-field `Fatal` for a half it never touches. The five
Espryt read sites whose `isUnpack` argument decides which half they want are
`DirectGLES.cpp:7924`, `:9399`, `:10893`, `:11272` and `Utils.cpp:2302`; **the scout named them
and did not open them**, so p1 reads the argument at each before it writes the two rows.
### The FATAL rows
Three non-sticky fields, each off the reduced path for a different, checkable reason:
| field | why it is FATAL rather than pulled |
|---|---|
| `GetBoundTransformFeedbackName` | **dead** — read by no backend since D21 (`PipeInputs.h:232-234`). |
| `GetTransformFeedbackPausedPrimitiveCounter` | reachable only from class `kQuery`, which the reduced path never enters. |
| `GetProgramForDispatch` | reachable only from `kDispatch`; there is no compute on the reduced path. Sites: `DirectGLES.cpp:5779`, `VulkanRenderer.cpp:7327`, `:7379`. |
Plus **`MGPipeUnmigratedEmulation`'s five call sites**, which in a split build stop being
`(void)name;` and become `Fatal`: `Managers.cpp:5334` ("texture-remint-pull"),
`DirectGLES.cpp:8051` ("generate-mipmap-storage"), `:8702` ("generate-mipmap-cpu-fallback"),
`:8997` ("copy-image-shadow-mirror"), `:10623` ("get-tex-image-shadow"). **One function grows
teeth and five sites get them** — `PipeApply.cpp:2820`, today a no-op, which
`PipeApply.h:1055-1066` and `PipeApply.cpp:2812-2817` both already say is waiting for this
phase. None of the five is on the reduced path. (ROADMAP's P4a row says six sites; there are
**five calls and one comment** — `Managers.cpp:5287` is the comment.)
### The seven sticky forwards
`GetBufferBindingPointCount`, `GetProgramObject`, `GetTextureObject`,
`HasOpenTransformFeedbackSpan`, `ValidateProgramName`, `InvalidateCompileEnv`, `RecordError`
(`PipeInputs.h:569-577`; identity asserted `:135-137`; argued `Coverage.def:138-150`).
P5's assignment:
- the first five → **BARRIER-PULLED** (counted in `rsp`, `Fatal` under strict);
- `InvalidateCompileEnv`**`OnCapsInvalidated`**, i.e. the re-arriving caps snapshot (R-12);
- `RecordError`**BARRIER-PULLED**, counted in `rsp`; its *ordering* is P9's (`OnGlError`).
**And their exemption is cancelled in a split build.** Today F-class accessors carry no
`MGP_INPUT_CHECK` at all (`PipeInputs.h:563-568`) and `MGPipeInputFieldIsFresh`
(`generated/PipeFilled.inc:418-426`) answers "fresh" for a sticky field regardless of
generation. That is exactly backwards for this phase: these seven are the ones that hand a
**frontend object or a frontend write** straight to the backend, so the exit gate "an
unmigrated field read is Fatal" is structurally blind on the seven most dangerous fields.
Under `MOBILEGL_BUILD_DISAGGREGATED` the exemption is lifted so they enter `rsp`, and under
`MOBILEGL_IPC_STRICT_ERRORS=1` they become `Fatal` like any other BARRIER-PULLED row.
### The prerequisite nobody else owns: someone must stamp
`MGPipeApplyAccess` **deliberately does not stamp** the poison generations
(`PipeInputs.h:612-618`): *"a stamp says the filler published this for THIS verb, which is the
walk's statement, not the applier's"*. Under split the filler is in the other role, so nothing
stamps, every `FilledGen[]` stays 0, `MGPipeInputFieldIsFresh` returns false for **everything**,
and a purely-server-side read aborts on the **first** field inside `SyncRenderState`
`Fatal{UnmigratedPipeInput, "GetRenderStateParameters@<none>"}` — long before any interesting
case. **The server stamps at the verb boundary**: p1 defines what is stamped and for which
verb, v1 places the call (`Server/PipeApplier::StampVerbBoundary`). Neither half works alone,
and this is not in the ROADMAP row.
### Two sets with no field ids
**The conservative client GPU-write set.** One row per backend `MarkGpuWritten` site, mapped to
the client predicate that must fire, one unit case each:
| backend site | what it marks | when |
|---|---|---|
| `DirectGLES.cpp:570` | every SSBO binding point | draw/dispatch about to go out |
| `DirectGLES.cpp:618` | every bound atomic counter | every conformance case reads the increment back |
| `DirectGLES.cpp:2603` | buffer textures on image units, **only when `Access != GL_READ_ONLY`** | from draw preparation, deliberately not from `glBindImageTexture` |
| `UniformManager.cpp:1075` | storage texel buffer, `Access != GL_READ_ONLY` | after `EnsureGpuResidentStorage()` |
| `UniformManager.cpp:1231` | SSBO block, unconditional | after `EnsureGpuResidentStorage()` |
| `VulkanRenderer.cpp:11618` | the XFB capture targets | "the capture is a GPU write like any shader's" |
Plus **two new producers P5 adds**, both client-side with no server participation
(`ARCHITECTURE.md:508`): `glReadPixels` into a pack PBO becomes fire-and-forget plus a client
`MarkGpuWritten` (strictly better than monolith's unconditional stall), and
`glEndTransformFeedback` drops its unbounded fence wait and marks the capture targets instead.
`SyncGpuWrites` gains a **third state** it cannot express today — *emitted, answer not back*
and under split it must **block until `OnBufferWriteback` lands** rather than clear the flag
optimistically (`BufferObject.cpp:372-374` clears unconditionally; `ARCHITECTURE.md:509` lists
this among the unavoidable blocking points, because monolith already `glFinish()`es here).
**No narrowing in P5**: `ResourceTracker.h:587-592`'s `rangeCount == 1` assertion **stays**.
**The persistent-map reachable set: the census is 21 sites, not 20.** `MEASUREMENTS.md:111`
records 20 and `Managers.cpp:5047-5048` speaks of "the eleven Espryt" sites; the actual count
is **9 Espryt + 12 Magma = 21**, and Espryt's own count is 12 (9 + 3 `SyncGpuWrites`), so both
published numbers are exactly one low and the missing one is an Espryt line. **Ruling: 21 is
the number, and `DirectGLES.cpp:361` (`ResolveIndirectCommandBytes`) is in the set.** It is a
shared helper rather than a draw-path site, which is the most likely reason it was excluded,
but a helper that reaches a persistently-mapped range is exactly as able to read stale bytes as
a draw site is; excluding it would be a shutter that cannot see its own subject. The
per-site attribution table `ARCHITECTURE.md:290` refers to as "§5.7" **does not exist in the
tree** — b1 should not go looking for it. The nine Espryt sites: `DirectGLES.cpp:361`, `:6185`,
`:6439`, `:6440`, `:6541`, `:6542`, `Managers.cpp:2817`, `:2994`, `MultiDraw.cpp:511`. The
twelve Magma: `DirectVulkan.cpp:281`, `:472`, `:796`, `UniformManager.cpp:2024`,
`VulkanRenderer.cpp:3542`, `:3621`, `:4013`, `:7428`, `:12423`, `:12424`,
`VkBufferManager.cpp:628`, `:679`. **Correcting `MEASUREMENTS.md:111` from 20 to 21 is b1's
line to write**, in the same commit that lands the tracker.
`m_livePersistentMaps` is defined by `SyncPersistentMappedRange`'s own early-out chain read as
a membership test (`BufferObject.cpp:346-349`): persistent, write, **not** flush-explicit,
**not** GPU-resident.
---
## §4 Table 3 — role and thread ownership of every process global
`ARCHITECTURE.md:578-581` claims MGPipe brings the globals a role split must duplicate down
from four to two. **That census is at least seven short.** Every row below also answers what
make-current and teardown do to it — the column `ARCHITECTURE.md` does not have.
| global | where | who writes | P5 ruling | make-current / teardown |
|---|---|---|---|---|
| `gPipeInputs` (~20 KB) | `PipeInputs.h:706` | client residual fill (`PipeFill.cpp:530`, `:2131`, `:2604`) + applier (`PipeApply.cpp:1336`, `:1364`, `:1373`, `:1377`, `:1436`, `:1512`) | **One instance is legal, but only under the verb barrier.** The barrier makes at most one of {GL thread, apply thread} runnable at a time, so there is exactly one writer at any instant. **No second writer may be introduced before the barrier retires.** The invariant is a runtime assertion in debug/verify builds, not only a sentence here: the apply thread raises a flag on entering the applier and the client checks it when it touches `gPipeInputs` outside a barrier (`ClientSession::InBarrierWait` / `ApplyThreadIsInsideApplier`). | make-current: unchanged. teardown: nothing — it is POD in the image. |
| `g_applier` | `PipeApply.cpp:396` | applier | **Server-exclusive.** Its own header already says "under split there is one per served context" (`PipeApply.h:684`). | It is `*new MGPipeApplierState{}` and never destroyed, deliberately (`PipeApply.cpp:392-395`): `resource_destroy` is raised from `~BufferObject`, which runs from exit handlers after this TU's globals are gone. |
| `g_resourceOps` | `PipeApply.cpp:402` | the backend, at register time | **Server-exclusive, and the client must NEVER read it** (R-8). Under `inproc` a client reading it is right *by accident*; under spawn it is null and the four P4a families plus P3a's buffers emit **nothing at all**, silently. The client asks `CapsMirror::ServerConsumes` instead. | registered around `DirectGLES.cpp:11933`, nulled from `OnBackendContextDestroyed` (`Managers.cpp:2584`) — so it moves on every context loss, which is another reason the client cannot key on it. |
| `gMGPipeSegmentResolver` | `MGPipeHostSpan.h:47` — a **plain non-atomic inline variable** | `MG_Remote` installs | **One process-wide slot, so it cannot be per-role.** Ruling: **the server role installs it and the client never resolves a span at all** — the client only ever *writes* `Ptr = nullptr`. `SegmentTable::InstallProcessResolver()` asserts if one is already installed, so two roles racing on it is loud rather than silent. Install **before** the apply thread starts. | teardown: uninstall after the join, never before — a record still in flight can still resolve. |
| the ten `MG_Impl/Pipe` `*Instance()` singletons | `fable-seam-audit.md:120-135` | client | **Client-exclusive.** One correction carried forward: the texture **drain list `m_drain` is process-wide**, not per-context as D-D4 claims; the audit already booked "one drain per client context" as a P5 item. | `FreshlyPrimed` (`PipeFill.cpp:2414-2440`) resets them on make-current; leak at exit (ID-8). |
| `ScopedDefaultUnpackState::s_synced` + **six** value shadows | `Managers.cpp:5490-5496` | backend | **Server-exclusive — the sixth global the four→two census missed.** Latent rather than live in P5 only because the client role never touches GL on the reduced path. (`Managers.cpp:5491-5496` is six `GLint`s, not five: `s_skipImages` at `:5496` is the one usually dropped.) | **Never reset on context death**`OnBackendContextDestroyed` resets the rings and the binding caches and not this. Benign while a lost context returns the driver to GL defaults; not benign the day a server re-attaches to a context something else moved. Register it now. |
| `pActiveBackendObject` | `GlobalObjects.cpp:23` | `MG_Backend::Init()` | **Client installs `BackendObject_Remote`; the server's `BackendObject_DirectGLES` is held privately by `ServerLoop`.** No thread-keyed shim, and therefore `MOBILEGL_BUILD_DISAGGREGATED_INPROC` needs none — but the cost is **seven** backend-internal reads across **six** functions, not the one the scout reported: `BackendObject_DirectGLES.cpp:815`, `:819` (`ClampSamplesToBackendSupport`) and `Utils.cpp:74`, `:82`, `:126`, `:220`, `:260`. All seven are format-capability lookups, so "pass the format cache down" still works. **`DirectGLES.cpp:12446` is NOT `ClampSamplesToBackendSupport`** — it is `Present()`'s fence poll, and `DirectGLES.cpp` contains no `pActiveBackendObject` reference at all. | `GetFormatCapabilities()` is **non-virtual** (`BackendObject.h:594`), so the remote object must **fill** `m_formatCapabilities` rather than override the accessor. Teardown: `pActiveBackendObject.reset()` (`MobileGL/Init.cpp:68`) runs `~BackendObject_DirectGLES``DestroyEGLContext()`, so under split it must be a **blocking** request onto the apply thread. |
| `gBackendFunctionsTable` | `GlobalObjects.cpp:24`, assigned `Init.cpp:44` | `MG_Backend::Init()` | **Client = the emit table (R-4); the server holds its real table directly and never goes through this global.** | cleared at `MobileGL/Init.cpp:91`. |
**Teardown order**, `ARCHITECTURE.md:537` plus the sentence it omits:
1. client publishes and waits for the server to drain and acknowledge;
2. **`Doorbell::Kill()`** — *the only thing that can wake an apply thread parked on
`kWaitForever`* (`Doorbell.h:211-221`; the shape is already pinned by
`InProcessTransportTest.cpp:344`);
3. **join**, bounded (that test uses 5 s) so a regression is a red test and not a hung CI job;
4. only then may the client free anything an emitter owns — a tail still referenced by an
unapplied record is a use-after-free the join is what prevents;
5. then the existing order (`MobileGL/Init.cpp:38-98`).
**ID-8 applies once per role-local singleton, not once overall**: every new
`MG_Remote/Client/*` and `MG_Remote/Server/*` singleton leaks at exit. The proof recipe is
inherited: both lanes run `GLIBC_TUNABLES=glibc.malloc.tcache_count=0`.
**Known open item, flagged not resolved.** `ARCHITECTURE.md:537`'s required order puts the
client's sync/query handle release **after** the transport closes, while today
`DestroyAllSyncObjects` / `DestroyAllQueryObjects` (`MobileGL/Init.cpp:62`, `:67`) deliberately
run **before** `pActiveBackendObject.reset()` (`:68`). The two are only reconcilable if a split
sync handle is client-minted and needs no backend call — which is P10's, not P5's. **P5 keeps
today's order** and v1 records which way it went.
---
## §5 The knobs
Parsed in `ConfigLoader.cpp`, declared in `Config.h`. All of them live behind
`#if MOBILEGL_BUILD_DISAGGREGATED` — including the parser — because `MG_ConfigLoader::Init()`
is a pull-build symbol and G1 admits **no resize**, which is the same reason the
`MOBILEGL_PIPE_VERIFY` knobs sit behind their own `#if`.
| knob | default | notes |
|---|---|---|
| `MOBILEGL_TRANSPORT` | `monolith` | `monolith\|inproc\|spawn\|unix:<path>\|pipe:<name>`. The three P6 forms **parse and are then refused by name**, staying on monolith: a P6 lane that set `spawn`, fell back silently and went green on the wrong arm is the failure this wording avoids. |
| `MOBILEGL_IPC_SERVER_PATH` | `""` | P6 consumes it; P5 parses it because t1's ctest `ENVIRONMENT` blocks and `add_trace_replay_test`'s SPLIT variant already carry it, and an unparsed variable is indistinguishable from a parsed-and-ignored one. |
| `MOBILEGL_IPC_RING_MB` | 8 | SEG_CMD. **One record may be at most half of this** (`RingProducer::MaxRecordBytes`), so 8 MiB caps a record at 4 MiB. R-10 makes the codec publish a max-record-bytes counter rather than assume that is enough. |
| `MOBILEGL_IPC_STAGE_MB` | 32 | SEG_STAGE. Every blob and every var-tail's bytes. |
| `MOBILEGL_IPC_SPIN_US` | 50 | spin before parking, either direction. |
| `MOBILEGL_IPC_PERSISTENT_BLOCK_KB` | 64 | **0 is the E3(a) negative control, not "unlimited"**: it turns the push off and `PersistentCoherentMapScenario` must go red. |
| `MOBILEGL_IPC_ADOPT_TIER` | 2 | 2 = emulate, the only tier P5 implements. 0 and 1 parse and are `Fatal` at use, naming P11. |
| `MOBILEGL_IPC_VERB_BARRIER` | 1 | 0 is R-1's negative control and is **expected** to be red. |
| `MOBILEGL_IPC_STRICT_ERRORS` | 0 | promotes BARRIER-PULLED reads — and, in a split build, the seven sticky forwards — to `Fatal`. |
| `MOBILEGL_IPC_AUDIT` | 0 | `0xDD` over retired staging bytes (rule C's mechanical control). |
| `MOBILEGL_IPC_SERVER_AFFINITY` | `auto` | kept as the raw string; whoever starts the apply thread logs the **resolved mask**, because an affinity that silently did nothing looks exactly like one that worked. |
**One consequence, stated so it is not rediscovered.** In a build *without*
`MOBILEGL_BUILD_DISAGGREGATED`, `MOBILEGL_TRANSPORT=inproc` is accepted by the environment and
**silently ignored** — the parser does not exist there, and putting a complaint in the
unconditional part of `ConfigLoader` would move a pull-build symbol and break G1. That is
precisely the shape of "the split lane ran monolith and went green", so the guard against it is
a **build-level** check, not a runtime one: `nm --defined-only libMobileGL.so | grep -i
MG_Remote` must be non-empty in `build-split`, and it is t1's CI job to assert that.
CMake gained `MOBILEGL_BUILD_DISAGGREGATED_INPROC` (implies `DISAGGREGATED`) and, new here,
**`MOBILEGL_BUILD_DISAGGREGATED` now implies `MOBILEGL_PIPE_PUSH`**: the split path decodes
into the MGPipe applier and `MOBILEGL_PIPE_PUSH` is what compiles the applier, so
`-DMOBILEGL_BUILD_DISAGGREGATED=ON` alone used to configure cleanly and then fail to link — a
shape indistinguishable at the CMake level from a legitimate transport-only build.
---
## §6 Rulings this file makes that the brief did not, and where the brief is wrong
Each entry says what would overturn it.
1. **`CallMask` bits 32..47 are the consumer mask.** R-8 says the client's liveness gates read
the `CallMask` mirror, but `CallMask` as declared has only nine feature bits and no
per-family bit, so R-8 was not implementable as written. Overturned by: a decision to carry
a second mask field in `CapsSnapshot` instead — which costs a schema field and gains
nothing, since 16 bits is enough through P8.
2. **`tableSlotMask` is deleted, not renamed** (R-8 allowed either). Decisive evidence:
`GLFunctionsTable` has **69** slots and `ulong` is 64 bits, so the field cannot address the
table its own comment names. Overturned by: widening the schema field *and* a reason to
keep an explicit slot probe after `ARCHITECTURE.md:114` retired the concept.
3. **`ResourceFlushRange` carries no bytes at all** (R-13.2 offered "add a blobref" or "write
the convention down"; this is a third answer, and a stronger one). The ladder it drives
rewrites from the authoritative shadow, which under rule C is server-owned, so
`resource_subdata` is already the only path bytes take. Overturned by: evidence that the
tier-1 `INVALIDATE_RANGE` arm needs bytes and range in one record. Cannot arise while the
verb barrier holds; **revisit when the barrier retires for the buffer family.**
4. **`ResourceRespecify` also has a SECOND uncarried companion, and the brief does not mention
it.** `const MGPRespecifiedLevel* level` (`PipeApply.h:792-795`) is the *scope* of the
redefinition and `MGPResourceDesc` cannot express it. Without a carrier every per-level
`glTexImage2D` in OpenRA silently takes the whole-resource arm and drops every pending
upload — the exact texel loss the server-side set exists to prevent. Ruling: two named
fields in the existing pads, zero size change. **c0 rules and specifies; the integrator
lands the `MGPipeTypes.h` + `PipeFields.def` edit before w1 encodes this record.**
5. **Table 1 is 23 rows, not 19.** The brief's 19 and `scout-premortem:§3`'s 19 are different
lists; the four only the premortem carries are the server → client ones, and a phase that
omits them discovers in week three that it never decided where readback pixels land.
6. **`SetResidualValueState` is a fourth typed companion, and neither scout nor the brief names
it.** `MGPipeApplySetResidualValueState` takes `const ResidualValueBlock&` — not a payload,
not a `const void*` — and `MGPResidualValueState` is **never instantiated on the live path**.
The encoder has to invent both the record fill and the blob fill. Budget it as w1's hardest
row, not as one of the easy `kHasBlob` eight.
7. **Three of the brief's 19 have no applier entry point at all**`SetShaderBuffers` (38),
`SetStreamOutputTargets` (39), `DrawVbo` (59). `scout-premortem:§3` cites
`PipeApply.h:756, 941, 1028-1030` for a six-call row; those five citations cover five *other*
calls. For these three, "what crosses today" is **nothing**, and P5 writes the first producer
*and* the first consumer.
8. **`CreateRenderState` and `SetDynamicState` declare a `Blob.Size` that nothing ever reads.**
`scout-wire-codec:§4.2`'s "`Size = 0`" column is stale for four rows (add
`CreateVertexElements` and `ResourceSubData`'s buffer half). The applier's only four
`Blob.Size` reads are `PipeApply.cpp:702`, `:1998`, `:2353`, `:2784`. A fifth
flags-vs-payload-vs-signature disagreement for the reviewer's list.
9. **The emit table is 71 function pointers, not 69.** R-4 says 69 slots; that is
`GLFunctionsTable`'s count. The table the client actually installs is
`GlobalBackendFunctionsTable` = 69 + `Present` + `SetSwapInterval`. R-4's rule (no null slot,
no pass-through) applies to all 71, and `Present` is on the reduced path. The Bool member is
not a verb and is answered from `kCapCpuXfbPrimitiveAccounting`.
10. **`prefersCpuXfbPrimitiveAccounting` is a member of `GLFunctionsTable`
(`BackendObject.h:274`), not of `DynamicBackendParameters`.** So it does **not** ride inside
`MGPCaps::Dynamic`, and R-8's "same redundancy as (6)" is the wrong frame — it has three
spellings and no carrier in `MGPCaps` except the cap bit. Its one non-test client reader is
`GL_Query.cpp:221`.
11. **The persistent-map census is 21 sites, and `MEASUREMENTS.md:111`'s 20 is wrong.** Ruling
and the missing site named above. **`ARCHITECTURE.md`'s cited "§5.7" attribution table does
not exist in the tree.**
12. **`ScopedDefaultUnpackState` has six value shadows, not five** (`Managers.cpp:5491-5496`);
both the brief and the scout say five.
13. **`CanTouchGLNow()` guards 16 call sites, not 19.** 19 is the raw grep: 1 definition
(`Managers.cpp:928`) + 2 comment mentions (`:1967`, `:1979`) + 16 calls. The brief's "19
sites" over-counts. `IsBackendContextCurrentOnThisThread`'s 16 is right.
14. **`MGPipeApply*` is 37 entry points and 41 call sites**, not "~45 entry points" — and
`DirectGLES.cpp:12446` is `Present()`'s fence poll, **not** `ClampSamplesToBackendSupport`
(which is `BackendObject_DirectGLES.cpp:807-828`). Table 3's `pActiveBackendObject` row is
still correct but the diff is six functions, not one line.
15. **The 18 `build-split` unit aborts are not a poison problem.** They are
`Fatal{ProtocolCorruption}` trip wires the tests *expect*; seven test TUs test
`MOBILEGL_PIPE_POISON` without including the only header that defines it, so the macro reads
as 0 and they compile the "logs and carries on" arm while `PipeApply.cpp` compiles the
aborting one. Invisible in a push build (where it really is 0) and in a verify build (where
`-DMOBILEGL_PIPE_VERIFY=1` is on the command line); `MOBILEGL_BUILD_DISAGGREGATED` is the one
arming condition behind the header. Fixed in c0's own commit, test-local, no p1 surface.
16. **`MGHostSpan`, not `MGPHostSpan`.** The header is `MGPipeHostSpan.h`; the struct is
`MGHostSpan` (`:28`). `MGPHostSpan` does not exist.
17. **`ARCHITECTURE.md`'s own corrections, carried here so they are not lost**: `:83` says 61
`PipeInputs` fields, it is 63; `:492` cites `PipeStats.h:126` for
`MapPersistentRoundtrips`, it is `:141`; ROADMAP's P4a row says six
`MGPipeUnmigratedEmulation` sites, it is five calls plus one comment; `ARCHITECTURE.md:19`
says eight EGL lifecycle virtuals, there are nine (`ResizeEGLWindowSurface` is the
uncounted one).
---
## §7 R-15 — getter-shaped slots are answered locally, and the emit table's three classes
**R-15 (integrator ruling, made after the verb census).** A `GLFunctionsTable` slot whose answer
is a **static property of the server's device** is answered on the client **from the caps
mirror**. It is never emitted and never `Fatal`. The gate already exists and already runs on
every lane: `AdvertisedLimitsScenario.ComputeWorkGroupLimitsAreTheCapsBlocksAnswer`
(`MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp:580-623`) pins that the caps copy and
`glGetIntegeri_v` give one number.
This settles the census's sharpest finding: `GetIntegeri_v` is reached by the **first
`glCompileShader` of every context** (`CompileEnv.cpp:134-138``Core.cpp:39`), not by any verb,
so an all-`Fatal` table would abort every scenario before it drew anything — and an emitter for
it would be a round trip for six constants the snapshot already carries.
### The three classes of the 71 slots. c1 does not re-derive this.
**Class A — answered locally from the caps mirror (2 slots). No record, ever.**
| slot | answered from |
|---|---|
| `GetIntegeri_v` (`BackendObject.h:205`) | `MGPCaps::Dynamic.MaxComputeWorkGroupCount` / `MaxComputeWorkGroupSize` (`BackendObject.h:392-393`) — the only indexed pnames the device owns. Every other indexed pname is frontend state and is answered before any table is consulted. |
| `IsTimerQuerySupported` (`:245`) | `kCapTimerQuery` (`MGPipeTypes.h:114`). A capability predicate, not a call: today a null slot means `COUNTER_BITS = 0` (`GL_Query.cpp:792`). |
`GLFunctionsTable::PrefersCpuXfbPrimitiveAccounting` (`:274`) is in the same class by the same
argument — `kCapCpuXfbPrimitiveAccounting` — and is not a slot.
**Class B — emitted in P5 (5 slots).** The verb census's answer, and nothing else:
`Clear`, `DrawArrays`, `ReadPixels`, `BlitFramebuffer`, `Present`.
`Present` is in this class despite having **zero `MG_Impl` call sites** — it is reached through
`EGLImpl.cpp:178``BackendObject.cpp:396`, so c1 cannot find it by mirroring GLImpl.
**Class C — `Fatal{UnmigratedVerb, "<slot>"}` (64 slots).** Everything else, including
`SetSwapInterval`, `GetGpuTimestampNs` (a live GPU timestamp, not a static property, so **not**
class A), and the whole sync / query / transform-feedback / compute / copy / mipmap surface.
### The cross-cutting rule R-4 would otherwise break
**Forty-one of the 69 slots are null-checked at their call site, and several of those null checks
are CAPABILITY PROBES rather than safety checks.** R-4 forbids a null slot — so in the emit table
every one of those probes answers "supported" and the fallback behind it silently disappears.
That is not a theoretical risk: it is how a split lane produces a plausible picture for the wrong
reason. Three named cases; the rule generalises to all 41.
| probe site | what it decides today | reads instead |
|---|---|---|
| `GL_Query.cpp:481`, `:785``BeginOcclusionQuery != nullptr` | whether the target is rejected outright | `kCapOcclusionQuery` |
| `GL_Query.cpp:534` — the `BeginXfbPrimitivesQuery` ternary | GPU query vs CPU primitive accounting | `kCapXfbPrimitivesQuery` |
| the `SubDataResident` op-table slot | whether the resident-upload path exists at all | `kCapResidentSubData` |
**A null check on a slot may never survive into the client under split.** It becomes a caps-mirror
read — class A's mechanism — whatever class the slot itself is in. That is exactly
`ARCHITECTURE.md:114`'s "`CallMask` replaces 'is this table slot null' as the implicit capability
probe", now with a concrete list of what has to move.
---
## §8 Ownership amendments
- **`MobileGL/MG_Pipe/MGPipeTypes.h` is c0's file** (integrator ruling A; the BRIEF §5 ownership
table is amended). It was unowned, which is how the respecify-scope gap in table 1 row 19b had
no one to close it. A package that needs a payload struct shape changed goes through the
integrator, as with the three `.def` files.
- Consequently the row-19b carrier is **landed, not merely specified** — see §2 table 1 row 19b
and `MGPipeTypes.h`'s `HasRespecifiedLevel` / `RespecifiedUploadTarget` / `RespecifiedLevel`
and the five `MGPipeRespecify*` helpers beside them.
+56
View File
@@ -0,0 +1,56 @@
// MobileGL - MobileGL/MG_Remote/CapsCodec.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 "CapsCodec.h"
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote {
// The consumer mask may not collide with the MGPCapBits below it. kCapNeedsHostUboBytes
// is 1<<8 today; this asserts the gap stays a gap rather than trusting the comment.
static_assert((static_cast<Uint64>(MG_Pipe::kCapNeedsHostUboBytes) & kMGCapsConsumerMask) == 0,
"an MGPCapBit has grown into CallMask's consumer block (bits 32..47)");
static_assert(MGCapsServerConsumes(MGCapsConsumerBits(MG_Pipe::kMGPipeSubsystemResources),
MG_Pipe::kMGPipeSubsystemResources),
"the consumer encoding does not round-trip");
static_assert(!MGCapsServerConsumes(MGCapsConsumerBits(MG_Pipe::kMGPipeSubsystemResources),
MG_Pipe::kMGPipeSubsystemPrograms),
"the consumer encoding answers yes for a family it was not given");
// P4a's highest allocated subsystem bit must fit the sixteen-bit block. This is the
// assertion that turns "room to P8" from a comment into a build break.
static_assert(MG_Pipe::kMGPipeSubsystemsMigratedAtP4a <= 0xFFFFull,
"the subsystem mask no longer fits CallMask's sixteen consumer bits");
#define MGP5_C0_STUB(what) \
do { \
MGLOG_F("MGPipe: Fatal{UnimplementedCapsCodec, \"%s\"} - P5 package w1 has not landed " \
"this yet; c0 shipped the signature only", \
what); \
std::abort(); \
} while (0)
Bool EncodeFormatCapabilities(const MG_Backend::FormatCapabilityCache&, Vector<Uint8>&) {
MGP5_C0_STUB("EncodeFormatCapabilities");
}
Bool DecodeFormatCapabilities(const void*, Uint64, MG_Backend::FormatCapabilityCache&) {
MGP5_C0_STUB("DecodeFormatCapabilities");
}
Bool EncodeRendererInfo(const RendererInfo&, Vector<Uint8>&) { MGP5_C0_STUB("EncodeRendererInfo"); }
Bool DecodeRendererInfo(const void*, Uint64, RendererInfo&) { MGP5_C0_STUB("DecodeRendererInfo"); }
Uint64 CapsAbiFingerprint() { MGP5_C0_STUB("CapsAbiFingerprint"); }
#undef MGP5_C0_STUB
} // namespace MobileGL::MG_Remote
+99
View File
@@ -0,0 +1,99 @@
// MobileGL - MobileGL/MG_Remote/CapsCodec.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 MGPCaps snapshot's serializers, and the CallMask encoding. Owner: package w1 (the two
// blob codecs) and s1 (the handshake asserts). Signatures by c0.
//
// MGPCaps (MG_Pipe/MGPipeTypes.h:126-139) has four members. Two are flat and cross by memcpy
// (DynamicBackendParameters Dynamic, Uint64 CallMask); two are MGPBlobRefs over containers
// and HAVE NO SERIALIZER IN THE TREE - the header says so itself at MGPipeTypes.h:134-136,
// "Their serializers land with the transport (P5)". They are:
//
// FormatCapabilities -> FormatCapabilityCache (MG_Backend/BackendObject.h:93-98):
// FullCaps + CaveatCaps (bitfield tables) + SampleCounts, a Vector<Int> per
// (target, format) pair. The Vector is why this cannot be a memcpy.
// RendererInfo -> RendererInfo (MG_Util/Types.h:317): three Strings, an
// Optional<String>, and a Vector<GLExtension> inside GLInfo.
//
// THE ABI RULING (table 0). MGPCaps has only a COMPOSITIONAL size assertion
// (MGPipeTypes.h:145-146) because DynamicBackendParameters still carries SizeT and GLenum
// members - P0.5's fixed-width rewrite did not happen. P5 does NOT rewrite it. Instead the
// handshake asserts that both peers agree on sizeof(DynamicBackendParameters),
// sizeof(MGPCaps) and the build fingerprint, and Fatal{AbiMismatch} otherwise. P6's spawn is
// same-machine and same-binary, so it inherits this unchanged; the fixed-width rewrite is on
// P7's account.
#pragma once
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Pipe/MGPipe.h>
namespace MobileGL::MG_Remote {
// ---- CallMask's layout (c0's ruling, extending R-8) ---------------------------------
//
// R-8 requires the client's liveness gates - MGPipeResourceSubsystemEnabled() and
// P4aFamilyHasItsConsumer() - to answer from MGPCaps::CallMask instead of from
// MGPipeGetResourceOps(), because that op table is the SERVER's registration: under
// inproc a client reading it is right by accident, and under spawn it is null and five
// whole record families silently emit nothing.
//
// But CallMask as declared carries only the nine MGPCapBit FEATURE bits
// (MGPipeTypes.h:108-124) and has no per-family bit at all, so "read the CallMask" was
// not yet an implementable instruction. It is now:
//
// bits 0..8 MGPCapBit, unchanged. kCapNeedsHostIndexBytes and kCapNeedsHostUboBytes
// are BOTH ZERO in P5 by ruling (table 0), which is what keeps every
// MGHostSpan out of the first IPC frame.
// bits 9..31 reserved for further MGPCapBits.
// bits 32..47 THE CONSUMER MASK: bit (32 + n) means "the server has a consumer for
// MGPipe subsystem bit n" - i.e. the server's own subsystem mask, shifted.
// Sixteen bits covers bits 0..12 allocated through P4a with room to P8.
// bits 48..63 reserved.
//
// protocol.fbs:94's `tableSlotMask: ulong` is DELETED rather than renamed (R-8 offered
// either). Two reasons, and the second is decisive: ARCHITECTURE.md:114 says CallMask
// REPLACES "is this table slot null" as the capability probe, so a field whose comment is
// "which GLFunctionsTable slots the peer registered" re-introduces exactly what it
// replaced; and GLFunctionsTable has SIXTY-NINE function-pointer slots
// (BackendObject.h:117-292), so a 64-bit mask cannot address it and never could.
inline constexpr Uint32 kMGCapsConsumerBitShift = 32;
inline constexpr Uint64 kMGCapsConsumerMask = 0xFFFFull << kMGCapsConsumerBitShift;
// Server side: fold the subsystems this server actually consumes into a CallMask.
inline constexpr Uint64 MGCapsConsumerBits(Uint64 subsystemMask) {
return (subsystemMask & 0xFFFFull) << kMGCapsConsumerBitShift;
}
// Client side: the ONE legal spelling of "does the server consume this family".
// `subsystemBit` is a kMGPipeSubsystem* constant (MG_Pipe/MGPipe.h), not an index.
inline constexpr Bool MGCapsServerConsumes(Uint64 callMask, Uint64 subsystemBit) {
return (callMask & MGCapsConsumerBits(subsystemBit)) != 0;
}
// ---- the two blob codecs ------------------------------------------------------------
//
// Byte-stable within one build; the handshake's fingerprint is what makes that enough.
// Both decoders must tolerate a truncated or over-long buffer by returning false, never
// by reading past `size`: these bytes arrive over the wire.
Bool EncodeFormatCapabilities(const MG_Backend::FormatCapabilityCache& cache, Vector<Uint8>& out);
Bool DecodeFormatCapabilities(const void* bytes, Uint64 size, MG_Backend::FormatCapabilityCache& out);
Bool EncodeRendererInfo(const RendererInfo& info, Vector<Uint8>& out);
Bool DecodeRendererInfo(const void* bytes, Uint64 size, RendererInfo& out);
// ---- the ABI assertion the handshake carries ----------------------------------------
//
// Mixes sizeof(DynamicBackendParameters), sizeof(MGPCaps), sizeof(GLFunctionsTable) and
// the compile-time build fingerprint. Compared in Hello/Welcome; a mismatch is
// Fatal{AbiMismatch} and never a downgrade, because every alternative silently reads one
// struct as another.
Uint64 CapsAbiFingerprint();
} // namespace MobileGL::MG_Remote
+63
View File
@@ -0,0 +1,63 @@
// MobileGL - MobileGL/MG_Remote/Client/CapsMirror.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
// P5 c0 stubs for package c1. Every body is MGLOG_F + std::abort and never a silent no-op: a
// caps accessor that answers a default is how a split lane runs on the wrong device's limits.
#include "CapsMirror.h"
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote::Client {
#define MGP5_C0_STUB(what) \
do { \
MGLOG_F("MGPipe: Fatal{UnimplementedCapsMirror, \"%s\"} - P5 package c1 has not landed " \
"this yet; c0 shipped the signature only", \
what); \
std::abort(); \
} while (0)
void CapsMirror::Adopt(const MG_Pipe::MGPCaps&, const MG_Backend::FormatCapabilityCache&,
const RendererInfo&, const String&, BackendType) {
MGP5_C0_STUB("CapsMirror::Adopt");
}
// Not stubs: the two the placeholder contract above promises are readable before the first
// snapshot. Everything else aborts, so nothing can accidentally answer from a zeroed mirror.
Bool CapsMirror::Valid() const { return m_generation != 0; }
Uint64 CapsMirror::Generation() const { return m_generation; }
const RendererInfo& CapsMirror::Renderer() const { MGP5_C0_STUB("CapsMirror::Renderer"); }
const MG_Backend::DynamicBackendParameters& CapsMirror::Dynamic() const {
MGP5_C0_STUB("CapsMirror::Dynamic");
}
const MG_Backend::FormatCapabilityCache& CapsMirror::Formats() const {
MGP5_C0_STUB("CapsMirror::Formats");
}
const String& CapsMirror::ApiVersion() const { MGP5_C0_STUB("CapsMirror::ApiVersion"); }
BackendType CapsMirror::Backend() const { MGP5_C0_STUB("CapsMirror::Backend"); }
Uint64 CapsMirror::CallMask() const { MGP5_C0_STUB("CapsMirror::CallMask"); }
Bool CapsMirror::HasCap(MG_Pipe::MGPCapBit) const { MGP5_C0_STUB("CapsMirror::HasCap"); }
Bool CapsMirror::ServerConsumes(Uint64) const { MGP5_C0_STUB("CapsMirror::ServerConsumes"); }
Bool CapsMirror::PrefersCpuXfbPrimitiveAccounting() const {
MGP5_C0_STUB("CapsMirror::PrefersCpuXfbPrimitiveAccounting");
}
CapsMirror& CapsMirrorInstance() {
// ID-8: leak at exit. No frontend destructor may reach pipe or backend state from an
// exit handler, and that rule applies once per role-local singleton, not once overall.
static CapsMirror& instance = *new CapsMirror{};
return instance;
}
#undef MGP5_C0_STUB
} // namespace MobileGL::MG_Remote::Client
+100
View File
@@ -0,0 +1,100 @@
// MobileGL - MobileGL/MG_Remote/Client/CapsMirror.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's copy of the server's capabilities. Owner: package c1. Signatures by c0.
//
// WHY A MIRROR AND NOT A ROUND TRIP. There are 56 client-side caps read points
// (40 GetDynamicParameters + 7 GetRendererInfo + 4 GetFormatCapabilities + 3 GetBackendType +
// 2 GetBackendAPIVersionString), and several of them - GL_Getter.cpp:2400 and
// ShaderTranspiler/CompileEnv.cpp:120-124 - bind a reference and then read many members, so a
// partial snapshot is not an option. Every one of the 56 must be answerable locally, with no
// record on the wire.
//
// GetRendererInfo() RETURNS A REFERENCE (BackendObject.h:590), so the mirror must OWN a
// RendererInfo instance to hand back - including before the first snapshot arrives, because
// LogBackendInfo() reads it at MG_Backend/Init.cpp:21, during MG_Backend::Init(), long before
// any context exists. Ruling (scout-caps-reply §1.2 option (a)): the mirror answers with a
// placeholder until the first snapshot, P5 accepts one inaccurate startup log line, and
// MG_Backend::Init() is NOT restructured.
//
// GetFormatCapabilities() is NON-VIRTUAL (BackendObject.h:594), so a remote backend object
// cannot override the accessor: it must FILL BackendObject::m_formatCapabilities from this
// mirror instead.
//
// INVALIDATION IS RE-ARRIVAL (R-12). DirectGLES has no OnCapsInvalidated producer at all - it
// re-runs UpdateAdvertisedCapabilityExtensions + UpdateDynamicBackendParameters at
// BackendObject_DirectGLES.cpp:865-871 and tells the frontend nothing, which is correct in
// monolith and a silent bug under split. Rather than add a DirectGLES-side callback (a
// dev-shaped backend edit), the SERVER re-sends the whole snapshot on every InitCapabilities
// re-run and the CLIENT treats a second arrival as the invalidation. Generation() is what a
// client-side memo keys on, and it is also the re-open signal for the server-context-death
// case that MGPipeCallbacks has no eleventh slot for (MGPipeCallbacks.h:56-58).
#pragma once
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
#include <MG_Pipe/MGPipe.h>
namespace MobileGL::MG_Remote::Client {
class CapsMirror {
public:
// Replaces the whole mirror and bumps Generation(). Called once per CapsSnapshot,
// including the re-sends that mean "invalidate" (R-12).
void Adopt(const MG_Pipe::MGPCaps& caps, const MG_Backend::FormatCapabilityCache& formats,
const RendererInfo& renderer, const String& apiVersion,
BackendType backend);
// False until the first snapshot. The placeholder answers below are still safe to
// read - that is the point - but a caller that can wait should.
Bool Valid() const;
// ++ on every Adopt. A client memo that survives a server context loss must key on
// this; nothing else on the client can see that the server's context died.
Uint64 Generation() const;
const RendererInfo& Renderer() const;
const MG_Backend::DynamicBackendParameters& Dynamic() const;
const MG_Backend::FormatCapabilityCache& Formats() const;
const String& ApiVersion() const;
// The SERVER's backend type, never a new "Remote" enumerator: frontend branches
// switch on this (GL_Framebuffer.cpp:47, GL_Texture.cpp:6536, CompileEnv.cpp:122) and
// a value they do not know silently takes the wrong arm.
BackendType Backend() const;
Uint64 CallMask() const;
Bool HasCap(MG_Pipe::MGPCapBit bit) const;
// R-8. `subsystemBit` is a kMGPipeSubsystem* constant. THIS IS THE ONLY LEGAL SOURCE
// of the answer on the client under split: MGPipeGetResourceOps() is the SERVER's
// registration and is null in the client process, which would silently disable the
// whole push path in the one mode that matters.
Bool ServerConsumes(Uint64 subsystemBit) const;
// GLFunctionsTable::PrefersCpuXfbPrimitiveAccounting (BackendObject.h:274) does NOT
// ride in MGPCaps::Dynamic - it is a member of the function table, which is precisely
// the thing a split client never receives. Its only non-test client reader is
// GL_Query.cpp:221, and under split it must be answered from kCapCpuXfbPrimitiveAccounting.
Bool PrefersCpuXfbPrimitiveAccounting() const;
private:
MG_Pipe::MGPCaps m_caps{};
MG_Backend::FormatCapabilityCache m_formats{};
RendererInfo m_renderer{};
String m_apiVersion;
BackendType m_backend = BackendType::Unknown;
Uint64 m_generation = 0;
};
// Per client context in principle; one per process in P5, because P5 serves one context.
// Leak-at-exit like every other MG_Remote singleton (ID-8): no frontend destructor may
// reach pipe or backend state from an exit handler.
CapsMirror& CapsMirrorInstance();
} // namespace MobileGL::MG_Remote::Client
@@ -0,0 +1,56 @@
// MobileGL - MobileGL/MG_Remote/Client/ClientSession.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
// P5 c0 stubs for packages s1 (construction, handshake) and c1 (barrier, reply read).
#include "ClientSession.h"
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote::Client {
#define MGP5_C0_STUB(what) \
do { \
MGLOG_F("MGPipe: Fatal{UnimplementedClientSession, \"%s\"} - P5 packages s1/c1 have not " \
"landed this yet; c0 shipped the signature only", \
what); \
std::abort(); \
} while (0)
// Null, not a Fatal: MG_Backend::Init() asks whether a session exists before it decides to
// install the remote backend object, and that question has a legitimate "no" - it is the
// monolith answer. Every call that PRESUMES a session aborts instead.
ClientSession* ClientSession::Active() { return nullptr; }
MobileGLResult ClientSession::Start(MG_Config::TransportMode, const String&) {
MGP5_C0_STUB("ClientSession::Start");
}
void ClientSession::Stop() { MGP5_C0_STUB("ClientSession::Stop"); }
Wire::PipeWireEncoder& ClientSession::Encoder() { return m_encoder; }
CapsMirror& ClientSession::Caps() { return CapsMirrorInstance(); }
Uint64 ClientSession::EmitAndWait(MG_Pipe::MGPWireOp, const void*, Uint64, const void*, Uint64,
void*, Uint64, Int32*) {
MGP5_C0_STUB("ClientSession::EmitAndWait");
}
Bool ClientSession::BarrierArmed() const { return m_barrierArmed; }
// False, not a Fatal, for both: these are the R-1 mutual-exclusion assertion's two probes,
// and an assertion helper that aborts when asked is worse than useless.
Bool ClientSession::InBarrierWait() { return false; }
Bool ClientSession::ApplyThreadIsInsideApplier() { return false; }
#undef MGP5_C0_STUB
} // namespace MobileGL::MG_Remote::Client
+95
View File
@@ -0,0 +1,95 @@
// MobileGL - MobileGL/MG_Remote/Client/ClientSession.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 half of a session: the rings, the handshake, the verb barrier. Owner: package s1
// (construction and handshake) with c1 (the barrier and the reply read). Signatures by c0.
//
// INPROC USES ShmSegment AND THE RING, NOT new[] AND NOT InProcessTransport's deques. That is
// half of what "inproc runs the same G3 codec as spawn" means: InProcessTransport
// (InProcessTransport.cpp:38-97) is two deque<vector<uint8_t>> plus two condvar doorbells, it
// touches neither a ring nor a codec, and building the session on top of it instead of on top
// of the ring would make the whole phase unfalsifiable. The transport supplies the two
// DOORBELLS and the control plane; the records go through SEG_CMD.
//
// THE VERB BARRIER (R-1). After emitting a verb the client blocks until
// RingControl::appliedSeq >= the seq it just got back from the encoder. It is not caution: 31
// of the 63 PipeInputs fields are still filled by the client's residual pass out of a live
// GLContext, so an unbarriered queue lets the server read a FUTURE value of them. Two
// consequences that must be stated because both are load-bearing:
// - while the barrier holds, at most one of {GL thread, apply thread} is runnable, which is
// what makes a single process-wide gPipeInputs legal (table 3);
// - the barrier is a RETIRING object, not a design. It opens family by family as table 2's
// BARRIER-PULLED column empties, and each later package reports how many rows it left.
//
// THE BARRIER'S WAIT IS ALSO THE REPLY'S WAIT (R-3/R-5). The reply slot id IS the record seq,
// so "wait for appliedSeq >= mySeq" and "wait for my answer" are one wait and the four Bool
// acceptance returns, ReadPixels' pixels and MapPersistent's decline cost ZERO extra round
// trips. The client MUST NOT re-derive any of those four answers locally - that is the c0f/c0g
// defect P4a paid two contract corrections for, and "always accept" is ID-39's 66 lost uploads.
#pragma once
#include <Includes.h>
#include <Config.h>
#include <MG_Pipe/MGPipe.h>
#include "../Wire/PipeWireCodec.h"
#include "CapsMirror.h"
namespace MobileGL::MG_Remote::Client {
class ClientSession {
public:
// Null until Start() succeeds; MG_Backend::Init() is the only caller of Start().
static ClientSession* Active();
// Builds the four segments, performs Hello/Welcome, takes the first CapsSnapshot, and
// - for TransportMode::InProcess - starts the server role's apply thread. Returns a
// named error rather than falling back to monolith: a fallback here is the "split lane
// ran monolith and went green" failure, and it must be loud.
MobileGLResult Start(MG_Config::TransportMode mode, const String& endpoint);
// Teardown order matters and is table 3's fourth column: publish and let the server
// drain, Doorbell::Kill() (the ONLY thing that wakes an apply thread parked on
// kWaitForever, Doorbell.h:211-221), then join, and only then release anything an
// emitter owns - a tail still referenced by an unapplied record is a use-after-free
// the join is what prevents.
void Stop();
Wire::PipeWireEncoder& Encoder();
CapsMirror& Caps();
// Emit one record and, if the barrier is armed, wait for it. `replyOut`/`replyBytes`
// name where a kReplySlot answer lands; pass {nullptr, 0} for a call that has none.
// Returns the record's seq, which is also its reply-slot id.
//
// Waiting is spin(MOBILEGL_IPC_SPIN_US) then park, through Doorbell::Wait, with
// producerParked set before blocking - the shape Doorbell.h:121 already implements.
Uint64 EmitAndWait(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes,
const void* varTail, Uint64 varTailBytes, void* replyOut,
Uint64 replyBytes, Int32* statusOut);
// MOBILEGL_IPC_VERB_BARRIER. False is the R-1 negative control and is EXPECTED to be
// red; it must be run once and the way it goes red recorded.
Bool BarrierArmed() const;
// R-1's invariant made checkable rather than only written down: true while this
// thread is inside a barrier wait. The apply thread sets its own flag on entry to the
// applier; a debug/verify build asserts the two are never both true, and that the
// client never touches gPipeInputs while the server is inside the applier.
static Bool InBarrierWait();
static Bool ApplyThreadIsInsideApplier();
private:
Wire::PipeWireEncoder m_encoder;
Wire::SegmentTable m_segments;
CapsMirror* m_caps = nullptr;
Bool m_barrierArmed = true;
};
} // namespace MobileGL::MG_Remote::Client
+47
View File
@@ -0,0 +1,47 @@
// MobileGL - MobileGL/MG_Remote/Client/EmitTables.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
// P5 c0 stubs for package c1.
#include "EmitTables.h"
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote::Client {
// The slot arithmetic, asserted rather than commented. GlobalBackendFunctionsTable is
// GLFunctionsTable plus Present plus SetSwapInterval; GLFunctionsTable is 69 function
// pointers plus one Bool (PrefersCpuXfbPrimitiveAccounting, BackendObject.h:274). A slot
// added to either without a decision here is a build break, which is the point: R-4 forbids
// a null slot, so a new slot needs an owner on the day it appears.
static_assert(sizeof(MG_Backend::GlobalBackendFunctionsTable) ==
sizeof(MG_Backend::GLFunctionsTable) + 2 * sizeof(void (*)()),
"GlobalBackendFunctionsTable is no longer GLFunctionsTable + Present + SetSwapInterval");
static_assert(sizeof(MG_Backend::GlobalBackendFunctionsTable) ==
kRemoteEmitSlotCount * sizeof(void (*)()) + sizeof(void (*)()),
"the emit table's 71 slots plus the packed Bool no longer describe the table");
[[noreturn]] void UnmigratedVerbFatal(const char* slot) {
// The same shape as MGPipeInputPoisonFatal (generated/PipeFilled.inc:407-413): names the
// slot, live at every log level, aborts. Deliberately NOT MOBILEGL_ASSERT, which is
// inert in an INFO build - and INFO is what every device lane runs.
MGLOG_F("MGPipe: Fatal{UnmigratedVerb, \"%s\"}", slot);
std::abort();
}
const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable() {
MGLOG_F("MGPipe: Fatal{UnimplementedEmitTable, \"RemoteEmitTable\"} - P5 package c1 has "
"not landed this yet; c0 shipped the signature only");
std::abort();
}
Uint32 ImplementedVerbCount() { return 0; }
} // namespace MobileGL::MG_Remote::Client
+80
View File
@@ -0,0 +1,80 @@
// MobileGL - MobileGL/MG_Remote/Client/EmitTables.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's emitting function table. Owner: package c1. Signatures by c0.
//
// MG_Backend/Init.cpp:44 assigns gBackendFunctionsTable from the active backend object, and
// 91 MG_Impl/GLImpl sites call through it directly. So a BackendObject_Remote has to return a
// COMPLETE table, and "complete" is a bigger number than R-4's headline:
//
// GLFunctionsTable (BackendObject.h:117-292) = 69 function pointers
// + Bool PrefersCpuXfbPrimitiveAccounting
// GlobalBackendFunctionsTable (BackendObject.h:293-299) = the above, + Present, + SetSwapInterval
// = 71 function pointers in total
//
// R-4's rule, restated over all 71: NO SLOT MAY BE NULL, and no slot may fall through to a
// driver. A null slot is 91 potential null calls; a pass-through slot is a split lane quietly
// running monolith and going green, which is the one outcome every gate in this phase exists
// to prevent. A verb P5 does not implement gets a slot that raises
// Fatal{UnmigratedVerb, "<slot>"} - the same shape as MGPipeInputPoisonFatal, live at every
// log level, MGLOG_F + std::abort.
//
// WHICH SLOTS GET A REAL EMITTER IS DECIDED BY THE VERB CENSUS (R-4), not guessed here:
// ~/w7/notes/p5/verb-census.md. CONTRACT-P5.md §7 carries the resulting THREE-CLASS SPLIT and
// it is not to be re-derived:
//
// A. ANSWERED LOCALLY from the caps mirror, never emitted and never Fatal (R-15) - two
// slots, GetIntegeri_v and IsTimerQuerySupported, plus the Bool member
// PrefersCpuXfbPrimitiveAccounting, which is not a slot. GetIntegeri_v is the one that
// would otherwise sink the phase: it is reached by the FIRST glCompileShader of every
// context (CompileEnv.cpp:134-138 <- Core.cpp:39), not by any verb, so a Fatal there
// aborts every scenario before it draws anything.
// B. EMITTED in P5 - five slots: Clear, DrawArrays, ReadPixels, BlitFramebuffer, Present.
// Present has ZERO MG_Impl call sites: it is reached through EGLImpl.cpp:178 ->
// BackendObject.cpp:396, so mirroring GLImpl will not find it.
// C. Fatal{UnmigratedVerb} - the remaining 64, SetSwapInterval and GetGpuTimestampNs among
// them.
//
// AND THE RULE R-4 WOULD OTHERWISE BREAK. 41 of the 69 slots are null-checked at their call
// site, and several of those checks are CAPABILITY PROBES, not safety checks - BeginOcclusionQuery
// (GL_Query.cpp:481, :785), BeginXfbPrimitivesQuery (:534), SubDataResident. With no null slot
// in this table every one of them answers "supported" and the fallback behind it silently
// disappears. A null check on a slot may not survive into the client: it becomes a caps-mirror
// read, which is what ARCHITECTURE.md:114 means by "CallMask replaces 'is this table slot null'".
//
// NOTE the asymmetry this table does not resolve: the resource, CSO, framebuffer, texture,
// sampler and program families do NOT come through here. They are emitted from
// MG_Impl/Pipe/* by direct MGPipeApply* calls (37 entry points, 41 call sites), and under
// split each of those becomes an encode. This table covers only the verbs - the draws,
// clears, blits, readbacks, queries, fences and present.
#pragma once
#include <Includes.h>
#include <MG_Backend/BackendObject.h>
namespace MobileGL::MG_Remote::Client {
// The table MG_Backend::Init() installs into gBackendFunctionsTable for the remote role.
// A reference to a never-destroyed block, like every other MG_Remote singleton (ID-8).
const MG_Backend::GlobalBackendFunctionsTable& RemoteEmitTable();
// Called by the Fatal slots. Named separately so a death test can filter on it and so
// that the message wording lives in exactly one place.
[[noreturn]] void UnmigratedVerbFatal(const char* slot);
// How many of the 71 slots have a real emitter. Reported at bring-up and asserted by the
// gate: a table that silently loses an emitter should not be able to look the same as one
// that never had it.
Uint32 ImplementedVerbCount();
// The total the count above is out of. Asserted against the struct in EmitTables.cpp, so
// a slot added to GLFunctionsTable without a decision here is a build break.
inline constexpr Uint32 kRemoteEmitSlotCount = 71;
} // namespace MobileGL::MG_Remote::Client
+51
View File
@@ -0,0 +1,51 @@
// MobileGL - MobileGL/MG_Remote/Server/PipeApplier.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
// P5 c0 stubs for package v1 (with p1 for the stamp rule).
#include "PipeApplier.h"
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote::Server {
#define MGP5_C0_STUB(what) \
do { \
MGLOG_F("MGPipe: Fatal{UnimplementedPipeApplier, \"%s\"} - P5 package v1 has not landed " \
"this yet; c0 shipped the signature only", \
what); \
std::abort(); \
} while (0)
ReplyPool::ReplyPool(void* base, Uint64 sizeBytes, Uint32 slotCount, Uint32 slotBytes)
: m_base(static_cast<Uint8*>(base)), m_size(sizeBytes), m_slots(slotCount), m_slotBytes(slotBytes) {}
void ReplyPool::PostReply(Uint64, Int32, const void*, Uint64) { MGP5_C0_STUB("ReplyPool::PostReply"); }
Uint32 ReplyPool::SlotBytes() const { return m_slotBytes; }
PipeApplier::PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies)
: m_segments(segments), m_replies(replies) {}
Bool PipeApplier::ApplyOne(const Transport::RingRecordView&) { MGP5_C0_STUB("PipeApplier::ApplyOne"); }
void PipeApplier::StampVerbBoundary(MG_Pipe::MGPWireOp) {
MGP5_C0_STUB("PipeApplier::StampVerbBoundary");
}
Uint64 PipeApplier::ResidualPullCount() const { return m_residualPulls; }
void PipeApplier::PoisonRetiredStageBytes(Uint64, Uint64) {
MGP5_C0_STUB("PipeApplier::PoisonRetiredStageBytes");
}
#undef MGP5_C0_STUB
} // namespace MobileGL::MG_Remote::Server
+102
View File
@@ -0,0 +1,102 @@
// MobileGL - MobileGL/MG_Remote/Server/PipeApplier.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 server's applier bridge. Owner: package v1, with p1 for the stamp rule. Signatures by c0.
//
// IT IS A BRIDGE, NOT AN APPLIER. The applier already exists and is not edited by this phase:
// MG_Pipe/PipeApply.{h,cpp}, 37 MGPipeApply* free functions. This class owns the three things
// that only exist once records arrive over a wire rather than by direct call:
//
// 1. THE VERB STAMP. This is the phase's prerequisite, and it is not in the ROADMAP row.
// MGPipeApplyAccess deliberately does not stamp the poison generations
// (PipeInputs.h:612-618): "a stamp says the filler published this for THIS verb, which is
// the walk's statement, not the applier's". Under split the filler is in another role, so
// NOTHING stamps, every FilledGen[] stays 0, MGPipeInputFieldIsFresh returns false for
// everything, and a pure server aborts on the FIRST read inside SyncRenderState with
// Fatal{UnmigratedPipeInput, "GetRenderStateParameters@<none>"} - before reaching any
// interesting case. So: the server stamps at the verb boundary. p1 defines what is
// stamped and for which verb; v1 places the call. Neither half works alone.
//
// 2. ACCEPTANCE. Four applier entry points return Bool - ResourceCreate, ResourceRespecify,
// ResourceSubData, SetTextureParams - and MapPersistent returns void*. Those returns are
// what the CLIENT gates destructive state changes on (clearing per-level dirty flags,
// latching parameters, adopting a pointer). They go back through the reply slot, id =
// record seq (R-3), and are collected in the barrier's existing wait (R-5). The client
// may not recompute any of them.
//
// 3. R-11, THE BORROWED-POINTER RULE. A SEG_STAGE run is valid from publish until retiredSeq
// passes the record naming it. NO APPLIER ENTRY POINT MAY HOLD A POINTER PAST ITS RETURN.
// The tree has exactly one violation and it is named: GLESBufferResource::hostBytes
// (Managers.h:839), written by Ops_H_SubData (Managers.cpp:1980-1983) and Ops_H_FlushRange
// (:2035), read by six later drains (:2000, :2062, :2080, :2111, :2741, :2843). Under split
// those two must copy into server-owned storage. MOBILEGL_IPC_AUDIT=1's 0xDD fill (R-2.5)
// is the mechanical control that says whether they did.
#pragma once
#include <Includes.h>
#include <MG_Pipe/MGPipe.h>
#include "../Transport/Ring.h"
#include "../Wire/PipeWireCodec.h"
namespace MobileGL::MG_Remote::Server {
// Writes answers into SEG_REPLY at seq % slots, stamping the seq back into the slot header
// so a wrong-slot read is detectable rather than plausible (table 0's slot header row:
// {Uint64 Seq; Int32 Status; Uint32 Size;}).
class ReplyPool final : public Wire::ReplySink {
public:
ReplyPool() = default;
ReplyPool(void* base, Uint64 sizeBytes, Uint32 slotCount, Uint32 slotBytes);
void PostReply(Uint64 seq, Int32 status, const void* bytes, Uint64 size) override;
// A reply larger than one slot is Fatal rather than chunked: P5's only large answer is
// ReadPixels, whose size the client already knows before it emits, so the slot size is
// chosen from that and an overflow means the two sides disagree about the frame.
Uint32 SlotBytes() const;
private:
Uint8* m_base = nullptr;
Uint64 m_size = 0;
Uint32 m_slots = 0;
Uint32 m_slotBytes = 0;
};
class PipeApplier {
public:
PipeApplier() = default;
PipeApplier(Wire::SegmentTable* segments, ReplyPool* replies);
// Decode one record, stamp the verb, apply, post the reply if the call has one, then
// advance appliedSeq by exactly one. P5 FORBIDS BATCHING appliedSeq (R-9): the barrier's
// waiter reads it, and a batched watermark promises work that has not run.
Bool ApplyOne(const Transport::RingRecordView& record);
// p1's rule, v1's call site. Called at the verb boundary, before the record's applier
// runs, with the verb the record belongs to.
void StampVerbBoundary(MG_Pipe::MGPWireOp op);
// R-7.2's counter, read by the gate. A BARRIER-PULLED field read on the server side
// increments PipeStats::CallClass::ResidualPulls (short name `rsp`); its value at the
// end of P5 IS the size of the P6/P7/P8 debt and goes into MEASUREMENTS.
Uint64 ResidualPullCount() const;
// R-11's audit: after a record retires, fill the SEG_STAGE bytes it referenced with
// 0xDD. Only under MOBILEGL_IPC_AUDIT=1, because it costs a write of every staged byte.
void PoisonRetiredStageBytes(Uint64 offset, Uint64 size);
private:
Wire::SegmentTable* m_segments = nullptr;
ReplyPool* m_replies = nullptr;
Wire::PipeWireDecoder m_decoder;
Uint64 m_residualPulls = 0;
};
} // namespace MobileGL::MG_Remote::Server
+49
View File
@@ -0,0 +1,49 @@
// MobileGL - MobileGL/MG_Remote/Server/ServerLoop.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
// P5 c0 stubs for package v1 - the phase's highest-risk package.
#include "ServerLoop.h"
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote::Server {
#define MGP5_C0_STUB(what) \
do { \
MGLOG_F("MGPipe: Fatal{UnimplementedServerLoop, \"%s\"} - P5 package v1 has not landed " \
"this yet; c0 shipped the signature only", \
what); \
std::abort(); \
} while (0)
MobileGLResult ServerLoop::Start(ServerSession&) { MGP5_C0_STUB("ServerLoop::Start"); }
void ServerLoop::Stop() { MGP5_C0_STUB("ServerLoop::Stop"); }
// Not a stub: teardown asks this to decide whether to Kill and join at all, and a teardown
// helper that aborts when the thread was never started is a hang in the shutdown path.
Bool ServerLoop::Running() const { return m_running; }
MG_Backend::BackendObject* ServerLoop::Backend() { MGP5_C0_STUB("ServerLoop::Backend"); }
MobileGLResult ServerLoop::RunOnApplyThread(ControlWork, void*) {
MGP5_C0_STUB("ServerLoop::RunOnApplyThread");
}
ServerLoop& ServerLoopInstance() {
// ID-8: leak at exit, like every MG_Remote singleton.
static ServerLoop& instance = *new ServerLoop{};
return instance;
}
#undef MGP5_C0_STUB
} // namespace MobileGL::MG_Remote::Server
+91
View File
@@ -0,0 +1,91 @@
// MobileGL - MobileGL/MG_Remote/Server/ServerLoop.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 apply thread and the server's private backend object. Owner: package v1 - the highest
// risk item in P5. Signatures by c0.
//
// WHY THE THREAD IS THE POINT. DirectGLES has 16 IsBackendContextCurrentOnThisThread() guards
// (DirectGLES.cpp:12034..12428) and Managers.cpp has 16 CanTouchGLNow() guards (:1494..:3428);
// every one of them DEGRADES when the answer is false - fences become always-signaled, queries
// return null handles, Present creates no frame fence so the buffer pool's recycle watermark
// never advances, and the two persistent-map acquisitions (Managers.cpp:1494, :2170) DECLINE,
// which would make PersistentCoherentMapScenario unreachable. Making the apply thread the
// context owner for life turns all 32 of those answers true on the server and removes the
// whole degradation class at once. It is also exactly the shape P6's spawned server inherits.
//
// P5 BUILDS ONE THREAD, NOT TWO. No mgl-srv-io: inproc's control plane is in the same process.
// P6 splits it.
//
// PARKING AND SHUTDOWN. The thread parks on Doorbell::Wait(consumerParked, ready, spinUs,
// kWaitForever) and shuts down when Wait returns false with Dead() set. Doorbell::Kill()
// (Doorbell.h:211-221) IS THE ONLY THING that wakes a thread parked on kWaitForever - a fact
// ARCHITECTURE.md's teardown order (:537) omits and InProcessTransportTest.cpp:344 already
// pins. Kill BEFORE join; join before the client frees any emitter-owned Vector; and the join
// must be bounded (that test uses 5 s) so a regression is a red test and not a hung CI job.
//
// THE EGL OWNERSHIP MOVE. eglMakeCurrent runs ONCE on this thread and is never released
// (DirectGLES.cpp:11925 plus the six cache invalidations at :11933-11953, which become a
// one-time startup cost instead of a per-make-current storm). The client's nine EGL virtuals
// become BLOCKING control requests executed here. ReleaseEGLResources and
// ~BackendObject_DirectGLES MUST be blocking: MobileGL::Destroy() (MobileGL/Init.cpp:68)
// otherwise walks on while the server still holds the context.
//
// THE FALLBACK IS PRE-DECLARED, NOT INVENTED UNDER PRESSURE (R-1). If the context migration is
// still not running ClearThenReadPixels at the end of v1's fourth working day, the integrator -
// not the package - declares `inproc-inline`: the client thread drains the ring itself, no
// thread is created, no context migrates, and a second package picks up the thread arm.
#pragma once
#include <Includes.h>
#include "ServerSession.h"
namespace MobileGL::MG_Remote::Server {
class ServerLoop {
public:
// Creates the apply thread, names it mgl-srv-apply, applies
// MOBILEGL_IPC_SERVER_AFFINITY (borrowing ShaderCompilePool's big-core detection) and
// LOGS THE RESOLVED MASK - an affinity that silently did nothing is indistinguishable
// from one that worked, and the split's whole performance claim rests on both halves
// landing on fast cores.
MobileGLResult Start(ServerSession& session);
// Kill the doorbell, join the thread (bounded), then destroy the private backend object
// ON THAT THREAD before it exits. Blocking by contract - see the header note.
void Stop();
Bool Running() const;
// The server role's own backend object. NOT pActiveBackendObject: that global holds the
// client's BackendObject_Remote. Table 3's ruling is that the server holds its
// BackendObject_DirectGLES privately here, and that the seven backend-internal reads of
// pActiveBackendObject - ClampSamplesToBackendSupport (BackendObject_DirectGLES.cpp:815,
// :819) and five in Utils.cpp (:74, :82, :126, :220, :260), all of them format-capability
// lookups - take the format cache as a parameter instead. That is six functions across
// two files, and it is why no thread-keyed shim is needed for MOBILEGL_BUILD_DISAGGREGATED_INPROC.
MG_Backend::BackendObject* Backend();
// Run one blocking control request on the apply thread and wait for it. This is how all
// nine EGL lifecycle virtuals cross; it is deliberately NOT a queue of async messages,
// because every one of them has a return value the caller acts on immediately.
//
// A raw function pointer plus a user pointer, not std::function: this runs on the
// teardown path too, and the teardown path may not allocate - ID-8's leak-at-exit rule
// exists because frontend destructors reach here from exit handlers.
using ControlWork = MobileGLResult (*)(void* user);
MobileGLResult RunOnApplyThread(ControlWork work, void* user);
private:
ServerSession* m_session = nullptr;
Bool m_running = false;
};
ServerLoop& ServerLoopInstance();
} // namespace MobileGL::MG_Remote::Server
@@ -0,0 +1,50 @@
// MobileGL - MobileGL/MG_Remote/Server/ServerSession.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
// P5 c0 stubs for package s1.
#include "ServerSession.h"
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote::Server {
#define MGP5_C0_STUB(what) \
do { \
MGLOG_F("MGPipe: Fatal{UnimplementedServerSession, \"%s\"} - P5 package s1 has not landed " \
"this yet; c0 shipped the signature only", \
what); \
std::abort(); \
} while (0)
ServerSession* ServerSession::Active() { return nullptr; }
MobileGLResult ServerSession::Accept(Transport::ITransport&) { MGP5_C0_STUB("ServerSession::Accept"); }
MobileGLResult ServerSession::PublishCapsSnapshot() {
MGP5_C0_STUB("ServerSession::PublishCapsSnapshot");
}
Transport::RingConsumer& ServerSession::CommandRing() { return m_commands; }
Transport::RingControl& ServerSession::Control() { MGP5_C0_STUB("ServerSession::Control"); }
Wire::SegmentTable& ServerSession::Segments() { return m_segments; }
PipeApplier& ServerSession::Applier() { return m_applier; }
ReplyPool& ServerSession::Replies() { return m_replies; }
Transport::Doorbell& ServerSession::ConsumerDoorbell() {
MGP5_C0_STUB("ServerSession::ConsumerDoorbell");
}
Transport::Doorbell& ServerSession::ProducerDoorbell() {
MGP5_C0_STUB("ServerSession::ProducerDoorbell");
}
#undef MGP5_C0_STUB
} // namespace MobileGL::MG_Remote::Server
+72
View File
@@ -0,0 +1,72 @@
// MobileGL - MobileGL/MG_Remote/Server/ServerSession.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 server half of a session: the consumer side of the rings, the handshake reply, the caps
// snapshot. Owner: package s1. Signatures by c0.
//
// The four segment sizes are already pinned by ProtocolSmokeTest.cpp:72 and are not up for
// re-derivation here: SEG_CMD 8 MiB, SEG_STAGE 32 MiB, SEG_REPLY 8 MiB, SEG_EVENT 256 KiB.
// MOBILEGL_IPC_RING_MB and MOBILEGL_IPC_STAGE_MB move the first two; the ring caps ONE record
// at half its size, so the default 8 MiB caps a record at 4 MiB (R-10).
//
// THE TWO DOORBELL ACCESSORS ARE ON THE CONCRETE CLASS, NOT ON ITransport
// (InProcessTransport.h:64-68). P5 decides this now rather than letting P6 discover it: the
// SESSION owns the pair and hands out references, so ITransport stays the dumb control-plane
// interface its header says it is and SocketTransport does not have to grow two accessors it
// has no natural home for. Discovering this in P6 would mean re-laying one package's call sites.
#pragma once
#include <Includes.h>
#include <MG_Pipe/MGPipe.h>
#include "../Transport/Doorbell.h"
#include "../Transport/ITransport.h"
#include "../Transport/Ring.h"
#include "../Wire/PipeWireCodec.h"
#include "PipeApplier.h"
namespace MobileGL::MG_Remote::Server {
class ServerSession {
public:
static ServerSession* Active();
// Maps the four segments, answers Hello with Welcome, and publishes the first
// CapsSnapshot. The ABI assertion (CapsCodec.h) happens HERE, before a single record is
// decoded: sizeof(DynamicBackendParameters), sizeof(MGPCaps), sizeof(GLFunctionsTable)
// and the build fingerprint must match, and a mismatch is Fatal{AbiMismatch}.
MobileGLResult Accept(Transport::ITransport& transport);
// Re-publishes the whole snapshot. R-12: a SECOND arrival IS the invalidation signal,
// which is how DirectGLES - which has no OnCapsInvalidated producer - tells the client
// its InitCapabilities re-ran, without any dev-shaped backend edit. It is also the
// re-open signal for "the server's ES context died and its rings were dropped", the
// event MGPipeCallbacks has no eleventh slot for (MGPipeCallbacks.h:56-58).
MobileGLResult PublishCapsSnapshot();
Transport::RingConsumer& CommandRing();
Transport::RingControl& Control();
Wire::SegmentTable& Segments();
PipeApplier& Applier();
ReplyPool& Replies();
// The client rings this one; the apply thread parks on it.
Transport::Doorbell& ConsumerDoorbell();
// The server rings this one, but only when producerParked is set (a store to a shared
// cache line otherwise burns a big core for a whole frame on a phone).
Transport::Doorbell& ProducerDoorbell();
private:
Transport::RingConsumer m_commands;
Wire::SegmentTable m_segments;
PipeApplier m_applier;
ReplyPool m_replies;
};
} // namespace MobileGL::MG_Remote::Server
+43
View File
@@ -41,6 +41,49 @@
// (Records.def / PipeCalls.def) is a separate deliverable; the ring itself
// only needs kind/flags/size, so it can carry the real records the day they
// land without changing shape.
//
// ---------------------------------------------------------------------------
// THE FIVE WATERMARKS (P5 R-9). One sentence each, and they are a contract:
// every one of the five was declared here at P0 and written by nobody but
// InitRingControl, so until P5 there was nothing to disagree with.
//
// submittedSeq Advanced by the PRODUCER after it publishes. NOBODY
// WAITS ON IT - it is diagnostic, the answer to "how far
// ahead of the server is the client right now".
// appliedSeq Advanced by the CONSUMER for EVERY SINGLE RECORD it
// applies. The client's verb barrier and every reply wait
// read it, so it is the one watermark P5 FORBIDS BATCHING:
// the sixty-four-record batching this ring was designed
// for makes a waiter block on work that already ran, or -
// far worse - resume on work that has not.
// retiredSeq Advanced by the CONSUMER once it has finished with the
// SEG_STAGE bytes a record referenced. The staging
// allocator reclaims behind it, and nothing else may.
// completedFrameSerial Advanced by the SERVER when a present completes. What
// recycling and ageing wait on; it trails appliedSeq by
// the GPU's own depth and must never be conflated with it.
// presentAckSerial Advanced by the SERVER when it returns a present credit.
// The client's present throttle waits on it; it is the
// only back-pressure that bounds latency rather than bytes.
//
// Every wait on all five is `>=`, never `==`: a waiter that tests equality
// misses the wakeup the moment a producer or consumer moves by more than one.
//
// BATCHING MAY ONLY MAKE A WATERMARK LATE. All five except appliedSeq may be
// published lazily, because a waiter that sees an old value waits longer than
// it had to and is still correct. NONE of them may ever be published EARLY: a
// watermark that reports more than was actually done turns every waiter into a
// silent use of work that has not happened, and there is no checksum anywhere
// on this ring that would catch it.
//
// kRecPad DOES NOT ADVANCE SEQ. A wrap filler is framing, not a record: it has
// no opcode, no payload meaning and no reply slot. Both sides must skip it
// BEFORE counting. If one side counts it and the other does not, the two seq
// spaces drift by one at every wrap - and because seq IS the reply-slot id
// (P5 R-3), a drifted seq silently reads another call's answer rather than
// failing. Nothing on this ring would detect that, which is why the rule is
// stated here rather than left to each side's loop.
// ---------------------------------------------------------------------------
#pragma once
+116
View File
@@ -0,0 +1,116 @@
// MobileGL - MobileGL/MG_Remote/Wire/PipeWireCodec.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
// P5 c0's stubs for package w1. Every body is MGLOG_F + std::abort and NOT a silent no-op:
// an unimplemented codec that returns quietly is exactly how a split lane runs monolith and
// goes green, which is the failure the whole phase is built to make impossible.
#include "PipeWireCodec.h"
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <MG_Util/Debug/Log.h>
#include <cstdlib>
namespace MobileGL::MG_Remote::Wire {
// Table 0's first row, mechanised: this enum and the schema's SegmentKind are ONE id
// space, and the only place they are compared is here. A schema edit that renumbers a
// segment is a build break rather than a wrong pointer on a ring.
//
// Fully qualified from the global namespace on purpose: the generated header's namespace
// is `MobileGL::Wire` and we are inside `MobileGL::MG_Remote::Wire`, so a bare `Wire::`
// resolves to THIS namespace and the assertion would silently be about the wrong enum -
// or, as it first was, fail to compile for a reason that looks unrelated.
static_assert(static_cast<Uint32>(::MobileGL::Wire::SegmentKind::None) == kSegNone);
static_assert(static_cast<Uint32>(::MobileGL::Wire::SegmentKind::Cmd) == kSegCmd);
static_assert(static_cast<Uint32>(::MobileGL::Wire::SegmentKind::Stage) == kSegStage);
static_assert(static_cast<Uint32>(::MobileGL::Wire::SegmentKind::Reply) == kSegReply);
static_assert(static_cast<Uint32>(::MobileGL::Wire::SegmentKind::Event) == kSegEvent);
static_assert(static_cast<Uint32>(::MobileGL::Wire::SegmentKind::Shadow) == kSegShadow);
static_assert(static_cast<Uint32>(::MobileGL::Wire::SegmentKind::Adopt) == kSegAdopt);
// And the other half of table 0's rule: MG_Pipe's "no segment" sentinel is the same 0.
static_assert(static_cast<Uint32>(MG_Pipe::kMGHostSpanSegNone) == kSegNone,
"kMGHostSpanSegNone and SegmentId::kSegNone must be the same value");
#define MGP5_C0_STUB(what) \
do { \
MGLOG_F("MGPipe: Fatal{UnimplementedWireCodec, \"%s\"} - P5 package w1 has not landed " \
"this yet; c0 shipped the signature only", \
what); \
std::abort(); \
} while (0)
void SegmentTable::Install(SegmentId, SegmentView) { MGP5_C0_STUB("SegmentTable::Install"); }
SegmentView SegmentTable::Get(SegmentId) const { MGP5_C0_STUB("SegmentTable::Get"); }
const void* SegmentTable::Resolve(Uint32, Uint64, Uint64) const {
MGP5_C0_STUB("SegmentTable::Resolve");
}
void SegmentTable::InstallProcessResolver() { MGP5_C0_STUB("SegmentTable::InstallProcessResolver"); }
void SegmentTable::UninstallProcessResolver() {
MGP5_C0_STUB("SegmentTable::UninstallProcessResolver");
}
// NOT a stub: the two Fatal helpers are the one thing every package needs on day one, and
// a Fatal that is itself unimplemented would report the wrong failure.
void WireProtocolFatal(const char* what, const char* detail) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} %s", what, detail != nullptr ? detail : "");
std::abort();
}
void WireProtocolFatalAt(const char* what, Uint64 got, Uint64 expected) {
MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"%s\"} got=%llu expected=%llu", what,
static_cast<unsigned long long>(got), static_cast<unsigned long long>(expected));
std::abort();
}
void CheckBlobIsHonest(MG_Pipe::MGPWireOp, const MG_Pipe::MGPBlobRef&, const SegmentTable&) {
MGP5_C0_STUB("CheckBlobIsHonest");
}
void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan&) { MGP5_C0_STUB("CheckHostSpanIsHonest"); }
PipeWireEncoder::PipeWireEncoder(Transport::RingControl* control, Transport::RingProducer* cmd,
Transport::RingProducer* stage, SegmentTable* segments)
: m_control(control), m_cmd(cmd), m_stage(stage), m_segments(segments) {}
Bool PipeWireEncoder::Valid() const { return m_control != nullptr && m_cmd != nullptr; }
MG_Pipe::MGPBlobRef PipeWireEncoder::StageBytes(const void*, Uint64) {
MGP5_C0_STUB("PipeWireEncoder::StageBytes");
}
Uint64 PipeWireEncoder::EncodeRecord(MG_Pipe::MGPWireOp, const void*, Uint64, const void*, Uint64) {
MGP5_C0_STUB("PipeWireEncoder::EncodeRecord");
}
void PipeWireEncoder::Publish() { MGP5_C0_STUB("PipeWireEncoder::Publish"); }
Uint64 PipeWireEncoder::EmitSeq() const { return m_emitSeq; }
Uint64 PipeWireEncoder::MaxRecordBytesSeen() const { return m_maxRecordBytes; }
PipeWireDecoder::PipeWireDecoder(Transport::RingControl* control, SegmentTable* segments,
ReplySink* replies)
: m_control(control), m_segments(segments), m_replies(replies) {}
Bool PipeWireDecoder::Valid() const { return m_control != nullptr && m_segments != nullptr; }
Bool PipeWireDecoder::DecodeAndApply(const Transport::RingRecordView&) {
MGP5_C0_STUB("PipeWireDecoder::DecodeAndApply");
}
Uint64 PipeWireDecoder::AppliedSeq() const { return m_applySeq; }
#undef MGP5_C0_STUB
} // namespace MobileGL::MG_Remote::Wire
+230
View File
@@ -0,0 +1,230 @@
// MobileGL - MobileGL/MG_Remote/Wire/PipeWireCodec.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
// G3: the MGPipe record codec. Owner: package w1.
//
// This header is the CONTRACT (MG_Remote/CONTRACT-P5.md) in C++ form; P5's c0 package wrote
// it so the other seven could compile on day one against signatures that cannot then move
// under them. Every body below is a named Fatal until w1 lands the real one.
//
// WHAT THIS LAYER IS, AND WHAT IT IS NOT
//
// It turns one MGPipe call into bytes in SEG_CMD (+ SEG_STAGE), and bytes back into ONE CALL
// OF AN EXISTING MGPipeApply* FREE FUNCTION. It owns NO semantics: MG_Pipe/PipeApply.cpp is
// not edited by this package, and a decoder arm that "handles" a record itself rather than
// delegating is a review failure (R-4's rule, one level down).
//
// THE FIVE HONESTY RULES (R-2), because they are what make `inproc` worth running at all.
// In the same address space every shortcut works: MGHostSpan::Ptr dereferences, a blobref
// whose Offset is a host address resolves, and MGPipeApplyMapPersistent's return value is a
// usable pointer. So the codec is held to the SPAWN rules even when it does not need to be:
// 1. encoder writes MGHostSpan::Ptr == nullptr and points Seg/Offset at SEG_STAGE;
// 2. encoder fills a real Seg, a real in-segment Offset and a NON-ZERO Size for every
// MGPBlobRef that carries content;
// 3. decoder Fatal{ProtocolCorruption} on: Ptr != nullptr; a content record with
// Blob.Size == 0; Size != 0 with Seg == kSegNone; Offset + Size past the segment;
// 4. MGPipeApplyMapPersistent returns nullptr under split (R-6; b1's half);
// 5. with MOBILEGL_IPC_AUDIT=1 the server fills a retired record's SEG_STAGE bytes with
// 0xDD, so an implementation that kept a pointer past apply reads 0xDD next frame.
//
// SEQ. The record ordinal IS the sequence number and IS the reply-slot id (R-3): there is no
// per-record seq field on the wire (ARCHITECTURE.md:124) and no second id space. Seq is
// 1-based so that 0 can mean "nothing encoded". A kRecPad wrap filler DOES NOT ADVANCE SEQ -
// both sides must skip it before counting, or every ring wrap offsets the two sides'
// numbering permanently and nothing checksums it (R-9, Ring.h's header).
#pragma once
#include <Includes.h>
#include <MG_Pipe/MGPipe.h>
#include "../Transport/Ring.h"
namespace MobileGL::MG_Remote::Wire {
// ---- table 0: the segment id space -------------------------------------------------
//
// The SAME VALUES as Protocol::SegmentKind (protocol.fbs:36-44); PipeWireCodec.cpp
// static_asserts the two agree, which is the only place the flatbuffers header and this
// enum meet. 0 is ALWAYS "no segment" and is never a real segment id, which is what lets
// MGPBlobRef{Seg == 0, Size != 0} be a detectable fault rather than a legal shape.
enum SegmentId : Uint32 {
kSegNone = 0,
kSegCmd = 1, // client-owned command ring (RingControl + records)
kSegStage = 2, // client-owned bulk staging: every blob and every var-tail's bytes
kSegReply = 3, // server-owned reply pool, addressed seq % slots (R-3)
kSegEvent = 4, // server-owned event ring (the reverse channel)
kSegShadow = 5, // client-owned per-object shadow (P8+)
kSegAdopt = 6, // server-owned adopted store, client RW (P11)
};
// Seq is 1-based. 0 is "no record", never a valid reply-slot id.
inline constexpr Uint64 kInvalidSeq = 0;
// One mapped segment as this ROLE sees it. Two roles in one process have two different
// SegmentTables over the same memory on purpose: a client that can resolve SEG_REPLY as
// if it owned it is the inproc cheat R-2 exists to kill.
struct SegmentView {
void* Base = nullptr;
Uint64 Size = 0;
};
// ---- the per-role segment table, and the process resolver hook ---------------------
//
// gMGPipeSegmentResolver (MG_Pipe/MGPipeHostSpan.h:47) is a plain non-atomic inline
// variable and there is exactly ONE of it per process, so under inproc the two roles
// cannot both install their own into it. TABLE 3's ruling: the resolver is installed by
// the SERVER role only, before the apply thread starts, and the client never resolves a
// span at all (it only ever writes Ptr = nullptr). Install() therefore takes the role.
class SegmentTable {
public:
void Install(SegmentId seg, SegmentView view);
SegmentView Get(SegmentId seg) const;
// Bounds-checked resolve. Returns nullptr when seg is unknown, size is 0, or
// offset + size runs past the segment; the CALLER escalates that to
// Fatal{ProtocolCorruption} (R-2.3) rather than this returning into a Fatal, so a
// unit test can exercise the arithmetic without dying.
const void* Resolve(Uint32 seg, Uint64 offset, Uint64 size) const;
// Points MG_Pipe::gMGPipeSegmentResolver at this table. Server role only; asserts if
// a resolver is already installed, because two roles racing on one inline variable is
// the failure this function exists to make loud.
void InstallProcessResolver();
static void UninstallProcessResolver();
private:
SegmentView m_views[kSegAdopt + 1];
};
// ---- the four Fatal arms, worded once ----------------------------------------------
//
// One function so encoder, decoder and every package's own bounds check produce the SAME
// log line. `what` is the record or field; `detail` is the number that was wrong.
[[noreturn]] void WireProtocolFatal(const char* what, const char* detail);
[[noreturn]] void WireProtocolFatalAt(const char* what, Uint64 got, Uint64 expected);
// R-2.3 arms 1-4 over one record's blobref. Split only; a monolith emission is exempt by
// construction because it never reaches this layer.
void CheckBlobIsHonest(MG_Pipe::MGPWireOp op, const MG_Pipe::MGPBlobRef& blob,
const SegmentTable& segments);
// R-2.3 arm for MGHostSpan. P5's reduced path should produce ZERO host spans
// (kCapNeedsHostIndexBytes / kCapNeedsHostUboBytes are both 0 in P5, table 0), so this
// firing at all is a finding, not just a corruption check.
void CheckHostSpanIsHonest(const MG_Pipe::MGHostSpan& span);
// ---- encoder -----------------------------------------------------------------------
//
// Not thread safe: one encoder per client context, driven by the GL thread, by
// construction (SPSC is the ring's contract too).
class PipeWireEncoder {
public:
PipeWireEncoder() = default;
PipeWireEncoder(Transport::RingControl* control, Transport::RingProducer* cmd,
Transport::RingProducer* stage, SegmentTable* segments);
Bool Valid() const;
// Copies `size` bytes into SEG_STAGE and returns the blobref that names them:
// {Seg = kSegStage, Offset = in-segment byte offset, Size = size}. R-2.2 - Size is
// NEVER 0 for a content blob, and a 0-size call is a programming error that Fatals
// rather than returning an empty ref, because "the record declared no blob" and "the
// record declared an empty blob" must not be spelled the same way on a wire.
//
// The bytes are valid until retiredSeq passes the record that names them (R-11).
MG_Pipe::MGPBlobRef StageBytes(const void* bytes, Uint64 size);
// Writes one record: header (op, MGPipeCallFlagsFor(op), total size), then the fixed
// payload, then the variable tail. Returns the record's SEQ, which is also its
// reply-slot id (R-3), or kInvalidSeq if the ring refused it.
//
// A record larger than RingProducer::MaxRecordBytes() is Fatal{RingOverrun}, NOT a
// wait: R-10 says P5 does no chunking and must instead PROVE it never needs any, so
// this is where the proof fails loudly if it is wrong. MaxRecordBytesSeen() is the
// counter that feeds that proof into MEASUREMENTS.
Uint64 EncodeRecord(MG_Pipe::MGPWireOp op, const void* payload, Uint64 payloadBytes,
const void* varTail = nullptr, Uint64 varTailBytes = 0);
// Release-stores the head cursor, then rings the consumer doorbell IF PARKED. The
// order is pinned by RingTest.cpp:446 and must not be swapped: notify-then-publish
// loses the wakeup.
void Publish();
// The highest seq this encoder has produced. The verb barrier (R-1) waits for
// RingControl::appliedSeq to reach it.
Uint64 EmitSeq() const;
// R-10's proof obligation: the largest single record this encoder has written.
Uint64 MaxRecordBytesSeen() const;
private:
Transport::RingControl* m_control = nullptr;
Transport::RingProducer* m_cmd = nullptr;
Transport::RingProducer* m_stage = nullptr;
SegmentTable* m_segments = nullptr;
Uint64 m_emitSeq = kInvalidSeq;
Uint64 m_maxRecordBytes = 0;
};
// ---- decoder -----------------------------------------------------------------------
// Where a kReplySlot answer goes. Declared HERE and not in Server/ so the codec does not
// depend on the server session: the decoder's job ends at "produce the answer bytes".
//
// The slot is addressed seq % slots and the server writes the seq back into the slot
// header for self-check (table 0's slot header row). Status: 0 = OK, 1 = DECLINED,
// 2 = ERROR. DECLINED IS A REAL ANSWER, not a failure - it is how MapPersistent says
// nullptr (R-6) and how the four Bool acceptance entry points say false (R-5).
class ReplySink {
public:
virtual ~ReplySink() = default;
static constexpr Int32 kStatusOk = 0;
static constexpr Int32 kStatusDeclined = 1;
static constexpr Int32 kStatusError = 2;
virtual void PostReply(Uint64 seq, Int32 status, const void* bytes, Uint64 size) = 0;
};
// Not thread safe: one decoder on the apply thread, by construction.
class PipeWireDecoder {
public:
PipeWireDecoder() = default;
PipeWireDecoder(Transport::RingControl* control, SegmentTable* segments,
ReplySink* replies);
Bool Valid() const;
// Decodes ONE record and calls the matching MGPipeApply* free function.
//
// TWO BOUNDS CHECKS, NOT ONE. The generated MGP_WIRE_CHECK_BOUNDS only proves
// `size >= sizeof(MGPWireRec_X)` - IT CANNOT SEE THE TAIL, so a record declaring
// Count = 4000 while carrying 8 bytes passes it today. The decoder must recompute the
// total from the declared count(s) and require it to EQUAL MGPWireRecHeader::Size.
// The three double-tailed shapes are SetShaderBuffers (MGPBufferRange[Count] then
// MGHostSpan[HostSpanCount]), SetStreamOutputTargets (MGPBufferRange[Count] then
// Uint32[Count]) and DrawVbo (MGPDrawRange[NumDraws] then a conditional MGHostSpan).
//
// Returns whether the record was applied. False is reserved for a record this build
// deliberately does not implement; a MALFORMED record never returns, it Fatals.
//
// A kRecPad record must be skipped by the CALLER before this is reached; passing one
// here Fatals, because a pad that reached the decoder has already been counted.
Bool DecodeAndApply(const Transport::RingRecordView& record);
// Advanced by exactly one per applied non-pad record. P5 FORBIDS BATCHING IT (R-9):
// the verb barrier's waiter reads it, and a batched watermark makes the client wait
// for records the server has not run.
Uint64 AppliedSeq() const;
private:
Transport::RingControl* m_control = nullptr;
SegmentTable* m_segments = nullptr;
ReplySink* m_replies = nullptr;
Uint64 m_applySeq = kInvalidSeq;
};
} // namespace MobileGL::MG_Remote::Wire
@@ -25,15 +25,22 @@ namespace MobileGL::MG_State::GLState {
#if MOBILEGL_PIPE_PUSH
FramebufferObject::~FramebufferObject() {
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not the
// glDelete* that only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage
// that twin owns, stop being reachable. The notice carries the lifetime id
// because the object no longer exists to be passed, and because the lifetime id
// is what the client slot allocator resolves the handle from. No-op unless a
// backend registered the ops (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Framebuffer, m_lifetimeId);
// 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
@@ -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,7 @@
#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
@@ -40,7 +41,21 @@ namespace MobileGL::MG_State::GLState {
// object no longer exists to be passed, and because the lifetime id is what the client
// slot allocator resolves the handle from. No-op unless a backend registered the ops
// (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::ShaderCso, m_lifetimeId);
//
// 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
}
@@ -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;
@@ -9,6 +9,10 @@
#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>
@@ -25,19 +29,33 @@ 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() {
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not
// glDeleteRenderbuffers, which only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage that
// twin owns, stop being reachable. The notice carries the lifetime id because the
// object no longer exists to be passed, and because the lifetime id is what the client
// slot allocator resolves the handle from. No-op unless a backend registered the ops
// (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Renderbuffer, m_lifetimeId);
// 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
@@ -96,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
@@ -59,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();
@@ -36,7 +36,22 @@ namespace MobileGL {
// because the object no longer exists to be passed, and because the lifetime id
// is what the client slot allocator resolves the handle from. No-op unless a
// backend registered the ops (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::SamplerCso, m_lifetimeId);
//
// 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
@@ -12,6 +12,13 @@
#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 {
@@ -29,15 +36,79 @@ namespace MobileGL {
#if MOBILEGL_PIPE_PUSH
TextureObjectBase::~TextureObjectBase() {
// P2 step e2: ANNOUNCE the death instead of leaving the backend to discover it in a
// garbage sweep. This is the last SharedPtr to this object dropping - not the
// glDelete* that only marks the name and leaves a still-bound object very much
// alive - so it is the exact moment the backend's twin, and the driver storage
// that twin owns, stop being reachable. The notice carries the lifetime id
// because the object no longer exists to be passed, and because the lifetime id
// is what the client slot allocator resolves the handle from. No-op unless a
// backend registered the ops (a pull build declares none at all).
NotifyStateObjectDestroyed(MG_Pipe::MGPipeKind::Texture, m_lifetimeId);
// 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
@@ -69,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 {
@@ -116,6 +215,14 @@ namespace MobileGL {
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 {
@@ -142,6 +249,9 @@ namespace MobileGL {
m_sampler->SetBorderColor(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
const IntVec4& TextureObjectBase::GetBorderColorI() const {
@@ -157,6 +267,9 @@ namespace MobileGL {
m_sampler->SetBorderColorI(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
const UintVec4& TextureObjectBase::GetBorderColorUI() const {
@@ -172,6 +285,9 @@ namespace MobileGL {
m_sampler->SetBorderColorUI(color);
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
BorderColorForm TextureObjectBase::GetBorderColorForm() const {
@@ -222,6 +338,9 @@ namespace MobileGL {
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
void TextureObjectBase::SetSwizzleParamRGBA(const Vec4<TextureSwizzleParam>& values) {
@@ -230,6 +349,9 @@ namespace MobileGL {
m_swizzleParams = values;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
const UintVec2& TextureObjectBase::GetLevelRange() const {
@@ -249,6 +371,12 @@ namespace MobileGL {
++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) {
@@ -261,6 +389,9 @@ namespace MobileGL {
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
BumpShapeVersion();
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
Bool TextureObjectBase::IsImmutable() const {
@@ -281,6 +412,14 @@ namespace MobileGL {
}
++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 {
@@ -319,6 +458,9 @@ namespace MobileGL {
m_samples = samples;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
Bool TextureObjectBase::HasFixedSampleLocations() const {
@@ -329,6 +471,9 @@ namespace MobileGL {
m_fixedSampleLocations = fixedSampleLocations;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
Uint64 TextureObjectBase::GetLifetimeId() const {
@@ -360,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,
@@ -383,6 +544,11 @@ namespace MobileGL {
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 {
@@ -395,6 +561,13 @@ namespace MobileGL {
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,
@@ -190,6 +190,12 @@ namespace MobileGL::MG_State::GLState {
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:
@@ -201,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;
@@ -31,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,
@@ -53,6 +63,11 @@ namespace MobileGL {
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 {
@@ -65,6 +80,9 @@ namespace MobileGL {
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
}
};
+16 -5
View File
@@ -135,16 +135,25 @@ if (MSVC)
target_compile_options(MagmaPipeIdentityTest PRIVATE /Zc:preprocessor)
endif()
# P3a's two suites. Their targets and this registration are the CONTRACT commit's, for the
# same reason the four P2 suites' are: their CONTENTS belong to two later packages each, and
# neither of them should have to come back to this file to add a case.
# P3a's two suites AND P4a's six, together because they take exactly the same shape. Their
# targets and this registration are the CONTRACT commit's, for the same reason the four P2
# suites' are: their CONTENTS belong to two later packages each, and neither of them should
# have to come back to this file to add a case.
#
# P4a's six are FramebufferEmitTest, TextureEmitTest, SamplerEmitTest, ImageEmitTest,
# ProgramEmitTest and CompositeResolverTest. Each lands from the contract commit with one case
# that pins the shape its later cases depend on - the emitter is one never-destroyed process
# singleton, or the composite band has exactly one door - so none of them is an empty file
# waiting for a package, and none of them can be registered wrongly without a red test.
#
# They link gtest rather than gtest_main and carry their own main(), like PipeInputsTest and
# RenderStateSpansTest: the applier's bounds and protocol trip wires report through a log line
# in a shipped push build and std::abort() in a poison or verify one, so a case that drives
# one reads the line back out of a file the process names before anything logs. Deciding that
# HERE is what keeps the later packages out of this file.
foreach(pipeTest ResourceEmitTest VertexInputEmitTest)
foreach(pipeTest ResourceEmitTest VertexInputEmitTest
FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest
ProgramEmitTest CompositeResolverTest)
add_executable(${pipeTest} ${pipeTest}.cpp)
target_include_directories(${pipeTest} PRIVATE
@@ -168,7 +177,9 @@ endforeach()
include(GoogleTest)
gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
foreach(pipeTest ResourceEmitTest VertexInputEmitTest)
foreach(pipeTest ResourceEmitTest VertexInputEmitTest
FramebufferEmitTest TextureEmitTest SamplerEmitTest ImageEmitTest
ProgramEmitTest CompositeResolverTest)
gtest_discover_tests(${pipeTest} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
endforeach()
gtest_discover_tests(MagmaPipeIdentityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,786 @@
// MobileGL - MobileGL/MG_Test/Pipe/CompositeResolverTest.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
// P4a's program-pipeline COMPOSITE: GLContext::GetProgramForDraw() already flattens a pipeline
// into one hidden composite ProgramObject entirely in the frontend, so the client pushes ONE
// handle for it, allocated out of the ShaderCso reserved high band, and the server never
// learns it is a composite - it needs no "resolved draw program" hook at all.
//
// WHAT THIS SUITE IS ACTUALLY FOR: the 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. Either order has to free the slot exactly
// once, and the second call has to be a proven no-op rather than a lucky one. That is what the
// eviction-then-destruction pair and its mirror pin, and it is why the composite gets a leak
// case of its own beside the five ordinary kinds.
//
// THE SUITE IS `CompositeResolver`, not `CompositeResolverTest`: the file is XTest.cpp and the
// suite is X, this directory's convention.
//
// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT - the
// resolver itself, its signature-keyed cache and the two release orders are the client
// package's, and it never has to come back to MG_Test/Pipe/CMakeLists.txt.
//
// IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull
// build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the
// pull and the push trees.
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.h"
// MOBILEGL_PIPE_POISON is DERIVED in the header below (PipeInputs.h:20-26) and nowhere
// else, so a TU that tests it without this include silently reads it as 0. That is
// invisible in a push build (where it really is 0) and in a verify build (where
// -DMOBILEGL_PIPE_VERIFY=1 is on the command line); MOBILEGL_BUILD_DISAGGREGATED is the
// one arming condition that lives behind the header, so a split build is the first place
// the refusals below stop being fatal while the expectations still say they are.
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/Program/GL_ProgramPipeline.h>
#include <MG_Impl/Pipe/CompositeResolver.h>
#include <MG_Impl/Pipe/ProgramEmit.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
// create_shader_state takes the two artefact structs by pointer beside the record, so a case
// that mints a composite record needs their definitions.
#include <MG_State/GLState/ProgramState/ProgramArtifacts.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
// `from` is a byte offset, and it exists because of the fork below: the library's log file
// is already open by the time a case runs, so the child's lines are APPENDED to it rather
// than written to a fresh file, and only what the child appended is this drive's evidence.
std::string ReadLog(std::streamoff from = 0) {
std::ifstream in(g_logPath, std::ios::binary);
if (from > 0) in.seekg(from, std::ios::beg);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// Where the library's log file currently ends. Reading from here after the child has
// aborted gives exactly the lines that drive produced.
std::streamoff LogEnd() {
std::ifstream in(g_logPath, std::ios::binary | std::ios::ate);
return in ? static_cast<std::streamoff>(in.tellg()) : std::streamoff{0};
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
// THE LOG PATH IS NOT UNLINKED HERE, and that is what this helper had to learn when the
// client's cases landed in the same file as the applier's: main() calls
// MobileGL::Initialize(), so the library's log FILE* is already open on this path and
// fork() duplicates it. Removing the path would leave the child writing into a deleted
// inode and the parent reading an empty file - the child would still abort, and the
// assertion on WHAT it named could never see the line. So the log's end is remembered
// and only what the child appended is read back.
const std::streamoff before = LogEnd();
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog(before);
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// The contract commit's one case, and it pins the property everything else in this suite is
// built on: the composite band has EXACTLY ONE DOOR. The ordinary allocator refuses the band
// for kind ShaderCso, AllocateComposite is the only way in, and a slot from one can never be
// mistaken for a slot from the other - which is what reserving a band rather than setting a
// flag on the handle buys, and what keeps the resolver's lifetime bookkeeping out of the
// ordinary program allocator.
TEST(CompositeResolver, TheCompositeBandHasExactlyOneDoor) {
#if MOBILEGL_PIPE_PUSH
MGPipeSlotAllocator slots;
// The ordinary door never opens onto the band, however many times it is used.
for (int i = 0; i < 8; ++i) {
const MGPipeHandle ordinary = slots.Allocate(MGPipeKind::ShaderCso);
EXPECT_FALSE(MGPipeHandleIsNull(ordinary));
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(ordinary.Slot));
}
// The composite door only ever opens onto it, and the handle it hands out is an ORDINARY
// ShaderCso handle in every other respect - the same kind, the same {slot, gen} rules, the
// same Free. The server cannot tell the difference and must not be able to.
const MGPipeHandle composite = slots.AllocateComposite(4242);
EXPECT_FALSE(MGPipeHandleIsNull(composite));
EXPECT_TRUE(MGPipeIsCompositeShaderSlot(composite.Slot));
EXPECT_TRUE(slots.IsLive(MGPipeKind::ShaderCso, composite));
EXPECT_EQ(slots.FindByLifetimeId(MGPipeKind::ShaderCso, 4242), composite);
// TWO RELEASE PATHS, ONE FREE. The second call resolves the same handle at a generation
// the slot no longer has, and Free refuses it - which is what makes "the pipeline cache
// evicted it and then the composite's destructor ran" safe in either order rather than a
// double free that only shows up as slot theft much later.
const Uint32 liveBefore = slots.LiveCount(MGPipeKind::ShaderCso);
slots.Free(MGPipeKind::ShaderCso, composite);
slots.Free(MGPipeKind::ShaderCso, composite);
EXPECT_EQ(slots.LiveCount(MGPipeKind::ShaderCso), liveBefore - 1);
EXPECT_FALSE(slots.IsLive(MGPipeKind::ShaderCso, composite));
// And the slot really goes back to the band rather than to the ordinary free list: the
// next composite reuses it with a bumped generation, and no ordinary program can be handed
// it.
const MGPipeHandle recycled = slots.AllocateComposite(4343);
EXPECT_EQ(recycled.Slot, composite.Slot);
EXPECT_NE(recycled.Gen, composite.Gen);
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(slots.Allocate(MGPipeKind::ShaderCso).Slot));
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client slot allocator in a pull build";
#endif
}
// =========================================================================================
// The APPLIER's half of the composite band (the wire commits'). The client-side resolver - the
// signature cache keyed on ComputeDrawProgramSignature, the two release paths, the eviction -
// is the client package's and lands beside these.
//
// WHY THE APPLIER HAS A BAND AT ALL. It is not because the server knows what a composite is:
// it does not, and create / bind / delete_shader_state name one exactly as they name any other
// program. It is because the band starts at 983040, so ONE pipeline composite in a
// slot-indexed vector would grow that vector to ~983k records of ~240 bytes each - a 236 MB
// spike on the first pipeline draw. Both spaces stay dense against their own high-water mark.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
using MG_State::GLState::LinkArtifacts;
using MG_State::GLState::SpirvArtifacts;
MGPProgramDesc CompositeDesc(MGPipeHandle cso, Uint32 stageMask) {
MGPProgramDesc desc{};
desc.Cso = cso;
desc.StageMask = stageMask;
return desc;
}
MGPHandleOnly ProgramHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::ShaderCso), 0};
}
} // namespace
#endif
// The band's record lands in the band's own table and the ordinary one is not grown by it -
// which is the whole 236 MB of it - and every entry point still names it as an ordinary
// program.
TEST(CompositeResolver, ACompositeRecordLandsInTheBandsOwnTableAndNeverGrowsTheOrdinaryOne) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle composite{kMGPipeShaderCsoCompositeSlotBase + 2, 1};
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(composite.Slot));
MGPipeApplyCreateShaderState(CompositeDesc(composite, 0x3u), &link, &spirv);
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty())
<< "one composite grew the ordinary table to the band's base - that is the 236 MB spike";
ASSERT_EQ(MGPipeApplier().CompositeShaderCsos.size(), 3u)
<< "the band's table is indexed by (slot - base) and stays dense against its own high water";
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos[2].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Gen, 1u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Desc.StageMask, 0x3u);
// AND THE SERVER NEVER LEARNS IT IS A COMPOSITE: the ordinary bind and draw-program calls
// resolve it exactly as they resolve any other program.
MGPipeApplyBindShaderState(ProgramHandle(composite));
MGPipeApplySetDrawProgram(ProgramHandle(composite));
EXPECT_EQ(MGPipeApplier().BoundShaderCso, composite);
EXPECT_EQ(MGPipeApplier().DrawProgram, composite);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
// An ordinary program lands in the other table, and the two do not see each other even
// though the composite's record is at index 2 of its own.
MGPipeApplyCreateShaderState(CompositeDesc(MGPipeHandle{2, 1}, 0x7u), &link, &spirv);
ASSERT_GT(MGPipeApplier().ShaderCsos.size(), 2u);
EXPECT_EQ(MGPipeApplier().ShaderCsos[2].Desc.StageMask, 0x7u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[2].Desc.StageMask, 0x3u)
<< "an ordinary program at slot 2 wrote the composite at band index 2";
#endif
}
// The composite's slot has TWO independent release paths - the pipeline cache's eviction and
// the composite program's own destructor - and both go through one client helper. The second
// arrival here is a refused no-op, which is what makes the double free proven rather than
// assumed, and it clears the bindings exactly once.
TEST(CompositeResolver, ASecondDeleteOfACompositeIsARefusedNoOpRatherThanASecondRelease) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle composite{kMGPipeShaderCsoCompositeSlotBase, 3};
MGPipeApplyCreateShaderState(CompositeDesc(composite, 0x3u), &link, &spirv);
MGPipeApplySetDrawProgram(ProgramHandle(composite));
ASSERT_EQ(MGPipeApplier().DrawProgram, composite);
MGPipeApplyDeleteShaderState(ProgramHandle(composite));
EXPECT_FALSE(MGPipeApplier().CompositeShaderCsos[0].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Gen, 3u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
const Uint64 serialAfterFirst = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(composite));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialAfterFirst)
<< "the second release moved the binding serial, so it was not a no-op";
// And the band's slot is re-usable afterwards: a recycled composite is a new identity and
// starts its record over.
MGPipeApplyCreateShaderState(CompositeDesc(MGPipeHandle{composite.Slot, 4}, 0x1u), &link, &spirv);
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos[0].Live);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Gen, 4u);
EXPECT_EQ(MGPipeApplier().CompositeShaderCsos[0].Serial, 0u);
#endif
}
// The band is INSIDE the ShaderCso slot limit, so the bound the applier refuses at is the limit
// itself and not the band's base - a bound below it would refuse the very slots the allocator's
// one composite door is allowed to hand out.
TEST(CompositeResolver, ASlotAtTheShaderCsoLimitIsRefusedWhileTheLastBandSlotIsNot) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
// The positive control: the LAST slot of the band is a legal composite handle.
const MGPipeHandle last{kMGPipeShaderCsoSlotLimit - 1, 1};
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(last.Slot));
MGPipeApplyCreateShaderState(CompositeDesc(last, 0x3u), &link, &spirv);
ASSERT_EQ(MGPipeApplier().CompositeShaderCsos.size(),
static_cast<SizeT>(kMGPipeShaderCsoSlotLimit - kMGPipeShaderCsoCompositeSlotBase));
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos.back().Live);
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
const MGPProgramDesc past = CompositeDesc(MGPipeHandle{kMGPipeShaderCsoSlotLimit, 1}, 0x3u);
ExpectRefusedNaming("create_shader_state {slot=1048576, gen=1}: the slot is outside the record table's "
"bound",
[&past, &link, &spirv]() { MGPipeApplyCreateShaderState(past, &link, &spirv); });
// And an ORDINARY slot at or above the band's base is out of range by definition: the
// allocator refuses the band for an ordinary program, so nothing legal can name one.
MGPipeApplySetDrawProgram(ProgramHandle(MGPipeHandle{kMGPipeShaderCsoCompositeSlotBase - 1, 1}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u)
<< "an ordinary slot below the band resolved against a record nobody created";
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 requires the pull and push ctest name sets to be identical, name for name.
#define MGL_COMPOSITE_RESOLVER_TEST_LIST(X) \
X(CompositeResolver, ACompositeIsMintedFromTheReservedBand) \
X(CompositeResolver, ASignatureThatHasNotMovedReusesOneComposite) \
X(CompositeResolver, TwoPipelinesWithTheSameSignatureKeepTheirOwnComposite) \
X(CompositeResolver, EvictionThenDestructionFreesTheSlotExactlyOnce) \
X(CompositeResolver, DestructionThenEvictionFreesTheSlotExactlyOnce) \
X(CompositeResolver, ASignatureMoveAfterAMakeCurrentStillReleasesThroughTheResolver) \
X(CompositeResolver, TwoContextsHoldingOnePipelineNameKeepTheirOwnComposites) \
X(CompositeResolver, ADestroyedContextsEntryIsDroppedRatherThanReleasedASecondTime)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_COMPOSITE_RESOLVER_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
namespace GL = MobileGL::MG_Impl::GLImpl;
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::ProgramObject;
using MG_State::GLState::ProgramPipelineObject;
struct ResolverScope {
ResolverScope() { Clear(); }
~ResolverScope() {
GL::BindProgramPipeline(0);
GL::UseProgram(0);
Clear();
}
ResolverScope(const ResolverScope&) = delete;
ResolverScope& operator=(const ResolverScope&) = delete;
static void Clear() {
MGPipeProgramEmitterInstance().Reset();
MGPipeProgramEmitterInstance().ResetCounters();
MGPipeCompositeResolverInstance().ResetCounters();
}
};
GLContext& Ctx() { return *MG_State::pGLContext; }
const char* kVs = R"(#version 430 core
out gl_PerVertex { vec4 gl_Position; };
void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); }
)";
const char* kFs = R"(#version 430 core
out vec4 o_color;
void main() { o_color = vec4(1.0); }
)";
// A SECOND fragment stage, so a pipeline's draw-program signature can be made to move for
// real: ComputeDrawProgramSignature is the per-stage {lifetimeId, GetLinkVersion()} array,
// and a different ProgramObject is a different lifetime id.
const char* kFs2 = R"(#version 430 core
out vec4 o_color;
void main() { o_color = vec4(0.5); }
)";
// Built by hand rather than through glCreateShaderProgramv, for ProgramPipelineCompositeTest's
// reason: that entry point detaches the shader right after linking, so a relink would leave
// the stage program with nothing to composite from.
GLuint MakeSeparableProgram(GLenum stage, const char* source) {
const GLuint shader = GL::CreateShader(stage);
GL::ShaderSource(shader, 1, &source, nullptr);
GL::CompileShader(shader);
const GLuint program = GL::CreateProgram();
GL::ProgramParameteri(program, GL_PROGRAM_SEPARABLE, GL_TRUE);
GL::AttachShader(program, shader);
GL::LinkProgram(program);
GLint linked = GL_FALSE;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "separable stage program did not link";
return program;
}
GLuint MakeBoundPipeline(GLuint vs, GLuint fs) {
GLuint pipeline = 0;
GL::GenProgramPipelines(1, &pipeline);
GL::BindProgramPipeline(pipeline);
GL::UseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs);
GL::UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs);
GL::UseProgram(0);
return pipeline;
}
TEST(CompositeResolver, ACompositeIsMintedFromTheReservedBand) {
ResolverScope scope;
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs);
MakeBoundPipeline(vs, fs);
const SharedPtr<ProgramObject> composite = Ctx().GetProgramForDraw();
ASSERT_TRUE(composite) << "the frontend has to flatten the pipeline for this to mean anything";
// The composite is the one ProgramObject in the system with external index 0: it is
// deliberately not a named program, must not answer glIsProgram and must not consume a
// name, and glCreateProgram never returns 0.
EXPECT_TRUE(MGPipeProgramIsPipelineComposite(*composite));
EXPECT_EQ(composite->GetExternalIndex(), 0u);
ASSERT_GT(MGPipeProgramEmitterInstance().EmitShaderState(Ctx()), 0u);
const MGPipeHandle cso = MGPipeProgramEmitterInstance().DrawCso();
ASSERT_FALSE(MGPipeHandleIsNull(cso));
EXPECT_TRUE(MGPipeIsCompositeShaderSlot(cso.Slot))
<< "a composite's slot comes out of the reserved band and nowhere else";
// AND IT IS AN ORDINARY create_shader_state. The server never learns it is a composite.
EXPECT_EQ(MGPipeProgramEmitterInstance().LastProgramDesc().Cso, cso);
EXPECT_TRUE(MGPipeProgramEmitterInstance().RecordIsPublished(cso));
EXPECT_EQ(MGPipeCompositeResolverInstance().GetCounters().Mints, 1u);
}
TEST(CompositeResolver, ASignatureThatHasNotMovedReusesOneComposite) {
ResolverScope scope;
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs);
MakeBoundPipeline(vs, fs);
ASSERT_GT(MGPipeProgramEmitterInstance().EmitShaderState(Ctx()), 0u);
const MGPipeHandle first = MGPipeProgramEmitterInstance().DrawCso();
ASSERT_FALSE(MGPipeHandleIsNull(first));
ASSERT_EQ(MGPipeProgramEmitterInstance().CreateCount(), 1u);
// The stage set did not move, so the frontend hands back the cached composite and the
// resolver reuses its handle - no second identity, no second record, and nothing
// released. THE KEY IS ComputeDrawProgramSignature's {lifetimeId, linkVersion} array
// and deliberately NOT GetBackendStateVersion, which a glUniform1i to a sampler moves
// and which used to rebuild the composite on every draw.
MGPipeProgramEmitterInstance().EmitShaderState(Ctx());
EXPECT_EQ(MGPipeProgramEmitterInstance().DrawCso(), first);
EXPECT_EQ(MGPipeProgramEmitterInstance().CreateCount(), 1u);
EXPECT_EQ(MGPipeCompositeResolverInstance().GetCounters().Releases, 0u);
EXPECT_GE(MGPipeCompositeResolverInstance().GetCounters().Reuses, 1u);
}
// [deviation] The brief names this case "TwoPipelinesWithTheSameSignatureShareOneComposite".
// Sharing one HANDLE between two pipeline objects is not implementable safely and the
// property that is true is the opposite one, so the case is named for what it asserts.
//
// The reason is the death path: a composite is an ordinary ProgramObject with its OWN
// lifetime id, and the client-side death helper resolves the handle FROM that lifetime id.
// Two composites sharing one handle would put only one of the two ids in the allocator's
// map, so the first ~ProgramObject would free a slot the second still names - a premature
// free that reappears later as slot theft, which is the exact class this whole band exists
// to prevent. The frontend does not share either: each ProgramPipelineObject carries its
// own one-slot draw-program cache, so two pipeline objects with identical stage sets are
// two composites in the frontend too.
TEST(CompositeResolver, TwoPipelinesWithTheSameSignatureKeepTheirOwnComposite) {
ResolverScope scope;
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs);
MakeBoundPipeline(vs, fs);
ASSERT_GT(MGPipeProgramEmitterInstance().EmitShaderState(Ctx()), 0u);
const MGPipeHandle firstCso = MGPipeProgramEmitterInstance().DrawCso();
ASSERT_FALSE(MGPipeHandleIsNull(firstCso));
MakeBoundPipeline(vs, fs); // a SECOND pipeline object, the same stage set
MGPipeProgramEmitterInstance().EmitShaderState(Ctx());
const MGPipeHandle secondCso = MGPipeProgramEmitterInstance().DrawCso();
ASSERT_FALSE(MGPipeHandleIsNull(secondCso));
EXPECT_NE(firstCso, secondCso);
EXPECT_TRUE(MGPipeIsCompositeShaderSlot(firstCso.Slot));
EXPECT_TRUE(MGPipeIsCompositeShaderSlot(secondCso.Slot));
// Both are still resolvable, which is the property a shared handle would have broken.
EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, firstCso));
EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, secondCso));
}
// THE TWO RELEASE ORDERS, driven at the level where both of them are representable. Either
// order frees the slot exactly once and the second call is a PROVEN no-op, because
// 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.
TEST(CompositeResolver, EvictionThenDestructionFreesTheSlotExactlyOnce) {
ResolverScope scope;
constexpr Uint64 kCompositeLifetimeId = 918273645ull;
const Uint32 liveBefore = MGPipeSlots().LiveCount(MGPipeKind::ShaderCso);
const MGPipeHandle cso = MGPipeSlots().AllocateComposite(kCompositeLifetimeId);
ASSERT_FALSE(MGPipeHandleIsNull(cso));
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(cso.Slot));
ASSERT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore + 1);
// 1. the pipeline cache drops it
MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId);
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, cso));
EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore);
// 2. and then ~ProgramObject runs, and finds nothing to do
MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId);
EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore);
}
TEST(CompositeResolver, DestructionThenEvictionFreesTheSlotExactlyOnce) {
ResolverScope scope;
constexpr Uint64 kCompositeLifetimeId = 918273646ull;
const Uint32 liveBefore = MGPipeSlots().LiveCount(MGPipeKind::ShaderCso);
const MGPipeHandle cso = MGPipeSlots().AllocateComposite(kCompositeLifetimeId);
ASSERT_FALSE(MGPipeHandleIsNull(cso));
// The mirror order, and it is the one that actually happens today: the frontend's
// one-slot cache drops its SharedPtr as it overwrites it, so ~ProgramObject usually
// runs first and the resolver's release is the second, redundant path.
MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId);
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, cso));
MGPipeEmitShaderCsoDestroyAndFree(kCompositeLifetimeId);
EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore);
// And the slot really came back: the next composite is handed the same slot with a
// bumped generation, so the stale handle can never resolve to it.
const MGPipeHandle recycled = MGPipeSlots().AllocateComposite(kCompositeLifetimeId + 1);
EXPECT_EQ(recycled.Slot, cso.Slot);
EXPECT_NE(recycled.Gen, cso.Gen);
MGPipeSlots().Free(MGPipeKind::ShaderCso, recycled);
}
// THE RELEASE PATH THE TWO CASES ABOVE DO NOT TOUCH. Both of them call the death helper
// directly, so the resolver is not in the picture at all and its own release - the
// pipeline-cache path, the one the header says exists precisely because "usually" is not a
// contract - had no case of its own. This drives it, and it drives it AFTER A
// MAKE-CURRENT, which is where it used to be permanently disarmed:
//
// Reset() cleared the entry's one flag; the next Observe took the reuse branch and
// returned before anything could restore it; from then on ReleaseEntry saw !Live and
// returned immediately - no delete_shader_state, no Free, no counter movement - and the
// old composite's handle was simply overwritten out of the resolver. Nothing leaked
// today only because the frontend's one-slot pipeline cache drops the last SharedPtr on
// the overwrite, which is exactly the "a client that only reacted to destructors" case
// the design refuses to rely on.
//
// The release obligation now lives in its own flag and a make-current does not touch it.
TEST(CompositeResolver, ASignatureMoveAfterAMakeCurrentStillReleasesThroughTheResolver) {
ResolverScope scope;
const GLuint vs = MakeSeparableProgram(GL_VERTEX_SHADER, kVs);
const GLuint fs = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs);
const GLuint pipeline = MakeBoundPipeline(vs, fs);
ASSERT_GT(MGPipeProgramEmitterInstance().EmitShaderState(Ctx()), 0u);
const MGPipeHandle first = MGPipeProgramEmitterInstance().DrawCso();
ASSERT_FALSE(MGPipeHandleIsNull(first));
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(first.Slot));
ASSERT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, first));
ASSERT_EQ(MGPipeCompositeResolverInstance().GetCounters().Releases, 0u);
// THE MAKE-CURRENT. This is exactly what PipeFill's FreshlyPrimed arm does, and it
// reaches MGPipeCompositeResolver::Reset() through the program emitter's own Reset().
MGPipeProgramEmitterInstance().Reset();
// ... followed by a draw whose stage set has NOT moved, which is the reuse branch.
MGPipeProgramEmitterInstance().EmitShaderState(Ctx());
EXPECT_EQ(MGPipeProgramEmitterInstance().DrawCso(), first)
<< "the same stage set is the same composite and the same handle";
EXPECT_GE(MGPipeCompositeResolverInstance().GetCounters().Reuses, 1u);
EXPECT_EQ(MGPipeCompositeResolverInstance().GetCounters().Releases, 0u)
<< "a reuse releases nothing";
// NOW THE STAGE SET REALLY MOVES: a different fragment stage program is a different
// lifetime id, so ComputeDrawProgramSignature moves and the frontend builds a second
// composite. The resolver has to speak the release for the first one.
const GLuint fs2 = MakeSeparableProgram(GL_FRAGMENT_SHADER, kFs2);
GL::UseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs2);
GL::UseProgram(0);
MGPipeProgramEmitterInstance().EmitShaderState(Ctx());
const MGPipeHandle second = MGPipeProgramEmitterInstance().DrawCso();
ASSERT_FALSE(MGPipeHandleIsNull(second));
EXPECT_NE(second, first) << "a moved signature is a second composite with its own handle";
EXPECT_EQ(MGPipeCompositeResolverInstance().GetCounters().Releases, 1u)
<< "the resolver's own release path, spoken after a make-current";
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, first))
<< "and the first composite's slot really went back, exactly once";
EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, second));
}
// THE RESOLVER IS A PROCESS SINGLETON AND A PIPELINE's GL NAME IS PER CONTEXT (C2-M1).
// GLContext owns m_programPipelines AND its own name generator m_programPipelineNames, 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 Observe after a
// make-current found the OTHER context's entry: the signature matched - two composites of
// the same stage set have the same signature, and here two default pipelines have the same
// all-zero one - while the handle could not, because two composites are two ProgramObjects
// with two lifetime ids. So it fell into ReleaseEntry and emitted delete_shader_state for a
// LIVE composite, cleared its publication latch and handed its band slot back while the
// frontend ProgramObject was still alive.
//
// DRIVEN AT THE RESOLVER RATHER THAN THROUGH GL, because one test process has one
// GLContext. Everything the case supplies is what the single production call site supplies:
// the context id is GLContext::GetTextureContextId()'s value, the two same-named pipeline
// objects are what two contexts hold, and the composites are ordinary ProgramObjects at
// external index 0 out of the reserved band, exactly as Core.cpp builds them.
TEST(CompositeResolver, TwoContextsHoldingOnePipelineNameKeepTheirOwnComposites) {
ResolverScope scope;
auto& resolver = MGPipeCompositeResolverInstance();
const LinkArtifacts link;
const SpirvArtifacts spirv;
constexpr Uint kSharedPipelineName = 9u;
constexpr Uint64 kContextA = 0x51A00001ull;
constexpr Uint64 kContextB = 0x51A00002ull;
const ProgramPipelineObject pipelineA{kSharedPipelineName};
const ProgramPipelineObject pipelineB{kSharedPipelineName};
const SharedPtr<ProgramObject> compositeA = MakeShared<ProgramObject>(0u);
const SharedPtr<ProgramObject> compositeB = MakeShared<ProgramObject>(0u);
ASSERT_TRUE(MGPipeProgramIsPipelineComposite(*compositeA));
ASSERT_EQ(pipelineA.GetExternalIndex(), pipelineB.GetExternalIndex());
const MGPipeHandle handleA = MGPipeSlots().AllocateComposite(compositeA->GetLifetimeId());
const MGPipeHandle handleB = MGPipeSlots().AllocateComposite(compositeB->GetLifetimeId());
ASSERT_NE(handleA, handleB);
for (const MGPipeHandle handle : {handleA, handleB}) {
MGPipeApplyCreateShaderState(CompositeDesc(handle, 0x3u), &link, &spirv);
MGPipeNoteHandlePublished(MGPipeKind::ShaderCso, handle);
}
const Uint64 releasesBefore = resolver.GetCounters().Releases;
EXPECT_EQ(resolver.Observe(kContextA, pipelineA, *compositeA, handleA), handleA);
// THE MAKE-CURRENT, which is what PipeFill's FreshlyPrimed arm reaches through the
// program emitter's own Reset()...
MGPipeProgramEmitterInstance().Reset();
// ... and then context B draws with ITS pipeline of the same name.
EXPECT_EQ(resolver.Observe(kContextB, pipelineB, *compositeB, handleB), handleB);
EXPECT_EQ(resolver.GetCounters().Releases, releasesBefore)
<< "the other context's entry was released - it is not this pipeline's entry";
EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, handleA))
<< "A's band slot went back while A's composite ProgramObject was still alive";
EXPECT_TRUE(MGPipeHandleIsPublished(MGPipeKind::ShaderCso, handleA))
<< "delete_shader_state went out for a live composite and cleared its latch";
EXPECT_TRUE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, handleB));
// AND BACK TO A. Its obligation stayed owed to its own context, so the unmoved
// signature is a reuse of the same handle and still nothing is released.
const Uint64 reusesBefore = resolver.GetCounters().Reuses;
MGPipeProgramEmitterInstance().Reset();
EXPECT_EQ(resolver.Observe(kContextA, pipelineA, *compositeA, handleA), handleA);
EXPECT_EQ(resolver.GetCounters().Reuses, reusesBefore + 1u);
EXPECT_EQ(resolver.GetCounters().Releases, releasesBefore);
}
// WHERE A DESTROYED CONTEXT's ENTRIES ARE RELEASED, and it is not in the resolver. The
// context's death drops m_programPipelines, which drops the ProgramPipelineObject, which
// drops the composite it cached; ~ProgramObject then runs the one client-side death helper
// and the band slot goes back EXACTLY ONCE. The resolver speaks no second delete - the
// entry can never be found again, and the allocator has erased the lifetime-id mapping
// anyway - and the next make-current DROPS the stranded entry, which is what keeps the
// vector bounded now that its key carries the context.
TEST(CompositeResolver, ADestroyedContextsEntryIsDroppedRatherThanReleasedASecondTime) {
ResolverScope scope;
auto& resolver = MGPipeCompositeResolverInstance();
const LinkArtifacts link;
const SpirvArtifacts spirv;
constexpr Uint kPipelineName = 11u;
constexpr Uint64 kDoomedContext = 0x51A00003ull;
// ResolverScope's Clear() has already run one Reset(), so every entry standing here has
// a live composite slot and nothing but this case's own entry can be swept below.
const SizeT sizeBefore = resolver.Size();
const Uint32 liveBefore = MGPipeSlots().LiveCount(MGPipeKind::ShaderCso);
const ProgramPipelineObject pipeline{kPipelineName};
SharedPtr<ProgramObject> composite = MakeShared<ProgramObject>(0u);
const MGPipeHandle handle = MGPipeSlots().AllocateComposite(composite->GetLifetimeId());
MGPipeApplyCreateShaderState(CompositeDesc(handle, 0x3u), &link, &spirv);
MGPipeNoteHandlePublished(MGPipeKind::ShaderCso, handle);
ASSERT_EQ(resolver.Observe(kDoomedContext, pipeline, *composite, handle), handle);
ASSERT_EQ(resolver.Size(), sizeBefore + 1u);
const Uint64 releasesBefore = resolver.GetCounters().Releases;
const Uint64 sweepsBefore = resolver.GetCounters().Sweeps;
// THE CONTEXT DIES: the last SharedPtr to its composite goes with its pipeline.
composite.reset();
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, handle))
<< "~ProgramObject is the release path for this entry and it freed the slot";
EXPECT_FALSE(MGPipeHandleIsPublished(MGPipeKind::ShaderCso, handle))
<< "and it took the publication latch with the delete";
EXPECT_EQ(MGPipeSlots().LiveCount(MGPipeKind::ShaderCso), liveBefore) << "exactly once";
EXPECT_EQ(resolver.GetCounters().Releases, releasesBefore)
<< "the resolver spoke no release of its own for it";
// THE NEXT CONTEXT's FIRST VERB. The stranded entry is dropped, not released.
MGPipeProgramEmitterInstance().Reset();
EXPECT_EQ(resolver.GetCounters().Sweeps, sweepsBefore + 1u);
EXPECT_EQ(resolver.GetCounters().Releases, releasesBefore)
<< "a dropped entry emits nothing and frees nothing - the obligation was discharged";
EXPECT_EQ(resolver.Size(), sizeBefore)
<< "the vector is bounded by the pairs whose composite slot is actually live";
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-compositeresolver-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
#if MOBILEGL_PIPE_PUSH
MobileGL::Initialize();
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}
File diff suppressed because it is too large Load Diff
+587
View File
@@ -0,0 +1,587 @@
// MobileGL - MobileGL/MG_Test/Pipe/ImageEmitTest.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
// P4a's third kVarTail unit set, set_shader_images. It rides the sampler family's subsystem
// bit: one family, one A/B.
//
// WHAT THIS SUITE IS FOR. The image set is the one whose ContentHash has to cover more than
// the binding: InternalFormat and Access are live glBindImageTexture state that the
// format-less image bake keys on, so a set whose only movement is an access mode still has to
// go out. And the zero early-out is the property an optimisation deletes by accident - it is
// what makes every draw of every application that never binds an image pay one integer test.
//
// THE SUITE IS `ImageEmit`, not `ImageEmitTest`: the file is XTest.cpp and the suite is X.
//
// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT.
//
// IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull
// build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the
// pull and the push trees.
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.h"
// MOBILEGL_PIPE_POISON is DERIVED in the header below (PipeInputs.h:20-26) and nowhere
// else, so a TU that tests it without this include silently reads it as 0. That is
// invisible in a push build (where it really is 0) and in a verify build (where
// -DMOBILEGL_PIPE_VERIFY=1 is on the command line); MOBILEGL_BUILD_DISAGGREGATED is the
// one arming condition that lives behind the header, so a split build is the first place
// the refusals below stop being fatal while the expectations still say they are.
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.h>
#include <MG_Impl/Pipe/ImageEmit.h>
#include <MG_Impl/Pipe/TextureEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
// `from` is a byte offset, and it exists because of the fork below: the library's log file
// is already open by the time a case runs, so the child's lines are APPENDED to it rather
// than written to a fresh file, and only what the child appended is this drive's evidence.
std::string ReadLog(std::streamoff from = 0) {
std::ifstream in(g_logPath, std::ios::binary);
if (from > 0) in.seekg(from, std::ios::beg);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// Where the library's log file currently ends. Reading from here after the child has
// aborted gives exactly the lines that drive produced.
std::streamoff LogEnd() {
std::ifstream in(g_logPath, std::ios::binary | std::ios::ate);
return in ? static_cast<std::streamoff>(in.tellg()) : std::streamoff{0};
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
// THE LOG PATH IS NOT UNLINKED HERE, and that is what this helper had to learn when the
// client's cases landed in the same file as the applier's: main() calls
// MobileGL::Initialize(), so the library's log FILE* is already open on this path and
// fork() duplicates it. Removing the path would leave the child writing into a deleted
// inode and the parent reading an empty file - the child would still abort, and the
// assertion on WHAT it named could never see the line. So the log's end is remembered
// and only what the child appended is read back.
const std::streamoff before = LogEnd();
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog(before);
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder.
TEST(ImageEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#if MOBILEGL_PIPE_PUSH
EXPECT_EQ(&MGPipeImageEmitterInstance(), &MGPipeImageEmitterInstance());
// The image set has no bit of its own: set_shader_images rides the SAMPLER subsystem,
// because the three unit sets are one family and an operator switching them off has to get
// the whole family's legacy arm.
EXPECT_EQ(kMGPipeMaxImageUnits, kMGPipeMaxTextureUnits);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build";
#endif
}
// =========================================================================================
// The APPLIER's half of set_shader_images (the wire commits'). The emitter's half - the
// high-water-zero early-out, the content hash covering Access and InternalFormat, the shutter
// keyed on the FRONTEND sampling-resolution generation - is the client package's.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
// Every field carries a value of its own, and two of them are the point: InternalFormat and
// Access are live glBindImageTexture state that the format-less image bake keys on, so a
// body that dropped either would leave the server baking against a format the shader was
// not built for.
MGPImageView ImageAt(Uint32 unit, Uint32 internalFormat, Uint8 access) {
MGPImageView view{};
view.Res = MGPipeHandle{unit + 1, 1};
view.Unit = unit;
view.InternalFormat = internalFormat;
view.Layer = 3;
view.Level = 2;
view.Layered = 1;
view.Access = access;
return view;
}
} // namespace
#endif
// The window rule, one field at a time: the entries land where the header says and nowhere
// else, and every field of an entry survives. Deleting the copy loop, the two window
// assignments or the serial bump leaves this red.
TEST(ImageEmit, TheImageSetLandsInItsWindowWithEveryFieldTheShaderWasBuiltAgainst) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// Access is a Uint8 on the wire - the client's own read/write/read-write encoding, not a
// GL enum - and InternalFormat is the application's, which the server recasts.
const MGPImageView entries[2] = {ImageAt(2, 0x8814u /* GL_RGBA32F */, 2 /* write only */),
ImageAt(3, 0x8230u /* GL_RG32F */, 3 /* read write */)};
MGPShaderImages header{};
header.Start = 2;
header.Count = 2;
header.ContentHash = 0x5150u;
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
// The other two sets' serials, taken AFTER the fixture: a reset and a teardown each advance
// every working serial, so "unchanged" is measured from here rather than from zero.
const Uint64 samplerViewsSerial = MGPipeApplier().SamplerViewsSerial;
MGPipeApplySetShaderImages(header, entries);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 2u);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Res, (MGPipeHandle{3, 1}));
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].InternalFormat, 0x8814u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Access, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].Access, 3u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Level, 2u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Layer, 3u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[2].Layered, 1u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].InternalFormat, 0x8230u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[1].Res)) << "the set wrote below its window";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[4].Res)) << "the set wrote above its window";
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
// "The last set as received": a narrower set says nothing about what it does not name.
MGPShaderImages narrow{};
narrow.Start = 2;
narrow.Count = 1;
MGPipeApplySetShaderImages(narrow, entries);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 1u);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[3].InternalFormat, 0x8230u)
<< "the entry outside the new window was cleared";
EXPECT_EQ(MGPipeApplier().SamplerViewsSerial, samplerViewsSerial)
<< "the image set moved another set's serial; the three are independent";
#endif
}
// The window gate, at the bound and one past it, and the null-tail arm. The image-unit space
// is the same merged 192 the sampler units are.
TEST(ImageEmit, AnImageWindowPastTheImageUnitSpaceIsRefusedRatherThanTruncated) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(0, 0x8058u, 2);
MGPShaderImages exact{};
exact.Start = kMGPipeMaxImageUnits - 1;
exact.Count = 1;
MGPipeApplySetShaderImages(exact, &entry);
ASSERT_EQ(MGPipeApplier().ShaderImageCount, 1u);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPShaderImages past{};
past.Start = kMGPipeMaxImageUnits;
past.Count = 1;
past.ContentHash = 9;
ExpectRefusedNaming("set_shader_images {start=192, count=1, hash=9}: the window runs past the merged "
"texture-unit space",
[&past, &entry]() { MGPipeApplySetShaderImages(past, &entry); });
MGPShaderImages noTail{};
noTail.Start = 0;
noTail.Count = 1;
ExpectRefusedNaming("set_shader_images {start=0, count=1, hash=0}: a non-empty set carries no entries",
[&noTail]() { MGPipeApplySetShaderImages(noTail, nullptr); });
EXPECT_EQ(MGPipeApplier().ShaderImagesSerial, serialBefore) << "a refused set moved the serial";
EXPECT_EQ(MGPipeApplier().ShaderImageStart, kMGPipeMaxImageUnits - 1);
#endif
}
// An EMPTY set is not a refusal: it is what a program with no image uniforms publishes, and it
// still moves the serial, because "no images" is a state the twin has to hear about.
TEST(ImageEmit, AnEmptySetIsAppliedRatherThanRefusedAndStillMovesTheSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(0, 0x8058u, 1);
MGPShaderImages filled{};
filled.Count = 1;
MGPipeApplySetShaderImages(filled, &entry);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPShaderImages empty{};
MGPipeApplySetShaderImages(empty, nullptr);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
EXPECT_EQ(MGPipeApplier().BoundShaderImages[0].InternalFormat, 0x8058u)
<< "an empty window cleared entries it never named";
#endif
}
// D-J4: the image set is per-context WORKING state, so a make-current takes it and ADVANCES
// its serial rather than restarting it.
TEST(ImageEmit, AMakeCurrentClearsTheImageSetAndAdvancesItsSerial) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPImageView entry = ImageAt(1, 0x8058u, 1);
MGPShaderImages header{};
header.Start = 1;
header.Count = 1;
MGPipeApplySetShaderImages(header, &entry);
const Uint64 serialBefore = MGPipeApplier().ShaderImagesSerial;
MGPipeApplierReset();
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_EQ(MGPipeApplier().ShaderImageStart, 0u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderImages[1].Res));
EXPECT_GT(MGPipeApplier().ShaderImagesSerial, serialBefore);
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 requires the pull and push ctest name sets to be identical, name for name.
#define MGL_IMAGE_EMIT_TEST_LIST(X) \
X(ImageEmit, AZeroHighWaterMarkEmitsNothingWithoutHashing) \
X(ImageEmit, AnAccessModeChangeAloneStillEmitsTheSet) \
X(ImageEmit, AnInternalFormatChangeAloneStillEmitsTheSet) \
X(ImageEmit, TheApplicationsFormatAndAccessTravelUnrecast) \
X(ImageEmit, AnImageBoundTextureIsMarkedShaderImageBoundAtTheBind)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_IMAGE_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
namespace GL = MobileGL::MG_Impl::GLImpl;
using GLContext = MG_State::GLState::GLContext;
struct EmitterScope {
EmitterScope() { Clear(); }
~EmitterScope() { Clear(); }
EmitterScope(const EmitterScope&) = delete;
EmitterScope& operator=(const EmitterScope&) = delete;
static void Clear() {
MGPipeImageEmitterInstance().Reset();
MGPipeImageEmitterInstance().ResetCounters();
MGPipeProgramOpaqueUnitsShared().Invalidate();
MGPipeSetHashSuppressorInstance().InvalidateAll();
}
};
GLContext& Ctx() { return *MG_State::pGLContext; }
MGPipeImageEmitter& Emitter() { return MGPipeImageEmitterInstance(); }
GLuint MakeComputeProgram(const char* source) {
const GLuint shader = GL::CreateShader(GL_COMPUTE_SHADER);
GL::ShaderSource(shader, 1, &source, nullptr);
GL::CompileShader(shader);
const GLuint program = GL::CreateProgram();
GL::AttachShader(program, shader);
GL::LinkProgram(program);
GLint linked = GL_FALSE;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "the compute program did not link";
return program;
}
GLuint MakeImageTexture() {
GLuint name = 0;
GL::GenTextures(1, &name);
GL::BindTexture(GL_TEXTURE_2D, name);
GL::TexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4);
return name;
}
// PROPERTY 1 OF D-G4, and it is the one an optimisation deletes: an application that never
// binds an image pays one integer test per draw, taken BEFORE any hash and before any
// 192-entry walk.
//
// The frontend has no image-unit high-water mark of its own - NoteUnitTouched is the
// TEXTURE-unit path and glBindImageTexture does not reach it - so the window is derived
// from the highest image unit the CURRENT PROGRAM names. A program with no image uniform
// names none, and the set is not emitted at all.
TEST(ImageEmit, AZeroHighWaterMarkEmitsNothingWithoutHashing) {
EmitterScope scope;
static const char* kNoImages = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Out { uint value; } outBuf;
void main() { outBuf.value = 1u; }
)";
const GLuint program = MakeComputeProgram(kNoImages);
GL::UseProgram(program);
// A texture IS bound to an image unit. The set still does not go out, because no
// shader can read it - which is the whole point of deriving the window from the
// program rather than walking 192 units to find out.
const GLuint texture = MakeImageTexture();
GL::BindImageTexture(0, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
EXPECT_EQ(Emitter().EmitShaderImages(Ctx()), 0u);
EXPECT_EQ(Emitter().ImageSetCount(), 0u);
EXPECT_EQ(Emitter().Window(), 0u);
GL::UseProgram(0);
}
const char* kImageCompute = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 1, rgba8) uniform image2D img;
void main() { imageStore(img, ivec2(0), vec4(1.0)); }
)";
// The record carries the APPLICATION's format and access verbatim. The bind-format recast -
// a GL_RG32F bind is INVALID_VALUE on most non-core formats on Adreno - and the
// buffer-texture split view are SERVER-side and stay there, so a client that pre-applied
// either of them would be answering a driver question from the wrong side of the boundary.
TEST(ImageEmit, TheApplicationsFormatAndAccessTravelUnrecast) {
EmitterScope scope;
const GLuint program = MakeComputeProgram(kImageCompute);
GL::UseProgram(program);
const GLuint texture = MakeImageTexture();
GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA8);
// THE FIXTURE HAS TO SET UP WHAT THE CASE IS ABOUT, and it is asserted rather than
// assumed: the window comes from the program's own image uniforms, so a shader whose
// image uniform did not reach the reflection would make every case below pass for the
// wrong reason - by emitting nothing at all.
const SharedPtr<MG_State::GLState::ProgramObject>& object = Ctx().GetProgramObject(program);
ASSERT_TRUE(object);
const auto& resolution = MGPipeProgramOpaqueUnitsShared().For(object.get());
ASSERT_EQ(resolution.MaxImageUnit, 1)
<< "maxUniformLocation=" << object->GetMaxUniformLocation()
<< " unit@0=" << object->GetUniformSamplerOrImageUnitIndex(0)
<< " linked=" << object->GetLinkStatus();
// ASSERTED ON THE COUNTER, NOT ON THIS CALL'S RETURN VALUE, and the reason is worth
// writing down because it surprised this suite: the glBindImageTexture above ALREADY
// reached the validate point and emitted the set, so a direct call afterwards is
// correctly suppressed as unchanged. What the case is about is what went out, not who
// sent it.
Emitter().EmitShaderImages(Ctx());
ASSERT_GE(Emitter().ImageSetCount(), 1u);
ASSERT_GE(Emitter().LastShaderImages().Count, 2u);
const MGPImageView& view = Emitter().LastImageViews()[1];
EXPECT_EQ(view.Unit, 1u);
EXPECT_FALSE(MGPipeHandleIsNull(view.Res));
EXPECT_EQ(view.InternalFormat, static_cast<Uint32>(GL_RGBA8));
EXPECT_EQ(view.Access, 1u) << "GL_WRITE_ONLY, folded into the one byte the wire carries";
EXPECT_EQ(view.Level, 0u);
EXPECT_EQ(view.Layered, 0u);
EXPECT_EQ(view.Layer, 0u);
GL::UseProgram(0);
}
TEST(ImageEmit, AnAccessModeChangeAloneStillEmitsTheSet) {
EmitterScope scope;
const GLuint program = MakeComputeProgram(kImageCompute);
GL::UseProgram(program);
const GLuint texture = MakeImageTexture();
GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
Emitter().EmitShaderImages(Ctx());
const Uint64 before = Emitter().ImageSetCount();
ASSERT_GE(before, 1u);
ASSERT_EQ(Emitter().LastImageViews()[1].Access, 0u) << "GL_READ_ONLY";
// The same texture, the same unit, the same format - only the access mode moves. The
// hash has to cover it, or a shader that now writes where it used to read is bound with
// the previous barrier and coherence semantics.
GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA8);
Emitter().EmitShaderImages(Ctx());
EXPECT_GT(Emitter().ImageSetCount(), before);
EXPECT_EQ(Emitter().LastImageViews()[1].Access, 2u);
GL::UseProgram(0);
}
TEST(ImageEmit, AnInternalFormatChangeAloneStillEmitsTheSet) {
EmitterScope scope;
const GLuint program = MakeComputeProgram(kImageCompute);
GL::UseProgram(program);
const GLuint texture = MakeImageTexture();
GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
Emitter().EmitShaderImages(Ctx());
const Uint64 before = Emitter().ImageSetCount();
ASSERT_GE(before, 1u);
ASSERT_EQ(Emitter().LastImageViews()[1].InternalFormat, static_cast<Uint32>(GL_RGBA8));
// The format the shader was built against is live glBindImageTexture state, and the
// format-less image bake keys on it: a set suppressed because "the binding did not
// move" would leave the server baking against the previous format.
GL::BindImageTexture(1, texture, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8UI);
Emitter().EmitShaderImages(Ctx());
EXPECT_GT(Emitter().ImageSetCount(), before);
EXPECT_EQ(Emitter().LastImageViews()[1].InternalFormat, static_cast<Uint32>(GL_RGBA8UI));
GL::UseProgram(0);
}
// FINAL REVIEW M-A: glBindImageTexture IS THE EARLIEST PRODUCER OF kMGPipeBindShaderImage -
// the bit the ImageBindableHint is derived from - and the emitted image set's walk is D-A4's
// (any texture named in an emitted MGPImageView). 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 arrive before the first sync, i.e. at the bind.
// Nothing produced the bit before the fix round.
TEST(ImageEmit, AnImageBoundTextureIsMarkedShaderImageBoundAtTheBind) {
EmitterScope scope;
MGPipeTextureEmitterInstance().ResetForTest();
const GLuint name = MakeImageTexture();
const auto& texture = Ctx().GetTextureObject(name);
ASSERT_TRUE(texture);
const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, texture->GetLifetimeId());
ASSERT_FALSE(MGPipeHandleIsNull(handle));
EXPECT_EQ(MGPipeTextureEmitterInstance().TextureBindMask(handle) & kMGPipeBindShaderImage, 0)
<< "nothing has image-bound this texture yet";
GL::BindImageTexture(0, name, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
EXPECT_NE(MGPipeTextureEmitterInstance().TextureBindMask(handle) & kMGPipeBindShaderImage, 0)
<< "glBindImageTexture did not mark the texture image-bound";
static const char* kOneImage = R"(#version 430 core
layout(local_size_x = 1) in;
layout(binding = 0, rgba8) uniform image2D img;
void main() { imageStore(img, ivec2(0, 0), vec4(1.0)); }
)";
const GLuint program = MakeComputeProgram(kOneImage);
GL::UseProgram(program);
Emitter().EmitShaderImages(Ctx());
ASSERT_GE(Emitter().Window(), 1u);
EXPECT_TRUE(Emitter().LastImageViews()[0].Res == handle);
EXPECT_NE(MGPipeTextureEmitterInstance().TextureBindMask(handle) & kMGPipeBindShaderImage, 0)
<< "the emitted image set's walk does not carry the bit either";
GL::UseProgram(0);
GL::BindImageTexture(0, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8);
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-imageemit-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
#if MOBILEGL_PIPE_PUSH
MobileGL::Initialize();
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}
+439 -2
View File
@@ -13,10 +13,19 @@
#include <gtest/gtest.h>
#include <cstring>
#include <iterator>
#include <limits>
#include <type_traits>
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
// P4a: MGPipeUnmigratedEmulation's declaration, and the applier's records the catalogue's size
// pins now reach. Push-only, like the translation unit that defines them - in a pull build the
// symbol does not exist and the one case that calls it is compiled out.
#if MOBILEGL_PIPE_PUSH
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Pipe/PipeApply.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
@@ -157,6 +166,239 @@ TEST(PipeCatalogue, ResidualBlockIsExactlyItsTwoValueStructsPlusPatchTail) {
EXPECT_EQ(sizeof(MGPBindRenderState), 12u);
}
// P4a's two payload edits, which are the only two the phase makes, and both are the kind a
// compiler catches only where somebody asked it to. MGP_ASSERT_POD already pins both sizes in
// MGPipeTypes.h; what is pinned HERE is the SHAPE the two edits were made for, because that is
// what a later phase would silently undo.
TEST(PipeCatalogue, TextureParamsNameTheirBuiltinSamplerAndFramebufferStateNamesItsTarget) {
// 32 -> 40: the CSO handle carrying the SamplerParameters of the SamplerObject every
// ITextureObject owns, plus the second resync bit. Naming the CSO rather than widening
// this payload with a filter/wrap/border block is what keeps ONE authority for one value -
// duplicating SamplerParameters on the wire would give two.
EXPECT_EQ(sizeof(MGPTextureParams), 40u);
EXPECT_EQ(offsetof(MGPTextureParams, Res), 0u);
EXPECT_EQ(offsetof(MGPTextureParams, BuiltinSampler), 8u);
EXPECT_EQ(offsetof(MGPTextureParams, SamplerResync), 26u);
// The two resync bits are SEPARATE bytes and must stay so: ForceResync guards a swizzle
// override the frontend params version does not move for, SamplerResync guards an
// incomplete texture sampling (0,0,0,1) after a driver re-mint. Different failures,
// different owners, one byte each.
EXPECT_NE(offsetof(MGPTextureParams, ForceResync), offsetof(MGPTextureParams, SamplerResync));
// Pad0 -> Uint8 Target, and the SIZE DID NOT MOVE, which is the whole point: the record
// describes one framebuffer OBJECT and Target says which binding(s), if any, it also
// sets, and that costs a byte the struct already had. Named (ID-19) cost nothing at all -
// it is a fourth value of a byte that was already there, which is why the applier could
// be given a per-object table without a wire change.
EXPECT_EQ(sizeof(MGPFramebufferState), 304u);
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Draw), 0u);
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Read), 1u);
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Both), 2u);
// Named = 3, and it is pinned by VALUE rather than merely by existence: the applier
// validates a record with `Target >= Count`, so an enumerator inserted ahead of Named
// would silently re-point every Named record the client already emits at Draw or Read -
// and a Draw record for a framebuffer that is not bound is the exact corruption Named
// exists to prevent (a DSA clear/blit landing on an unattached driver framebuffer).
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Named), 3u);
// Count is the applier's refusal bound and it is 4 now, not 3: a wire that still refused
// 3 would drop every DSA record on the floor.
EXPECT_EQ(static_cast<Uint8>(MGPipeFramebufferTarget::Count), 4u);
// The byte must be able to hold every value, since Target is a Uint8 in the record and
// the enum is the only thing that says what fits.
EXPECT_LE(static_cast<Uint32>(MGPipeFramebufferTarget::Count), 256u);
EXPECT_EQ(sizeof(MGPFramebufferState::Target), 1u);
// The wire's colour-attachment width is ONE width, and it is the wire's rather than the
// driver's: a driver reporting more attachments than this is refused at bring-up, never
// truncated into the record.
EXPECT_EQ(kMGPipeMaxColorAttachments, 8u);
EXPECT_EQ(std::extent_v<decltype(MGPFramebufferState::Color)>, kMGPipeMaxColorAttachments);
EXPECT_EQ(std::extent_v<decltype(MGPFramebufferState::DrawBuffers)>, kMGPipeMaxColorAttachments);
// And the two unit bounds, which bound all three var-tail sets. One merged unit space, no
// stage dimension.
EXPECT_EQ(kMGPipeMaxTextureUnits, 192u);
EXPECT_EQ(kMGPipeMaxImageUnits, 192u);
}
// D-A3: the resource-target enum minted beside the field, and the property that makes it worth
// minting - EVERY TextureTarget has a row, checked at compile time by a table with no
// `default:` arm, so adding a target is a build break rather than a descriptor that silently
// describes the wrong kind of storage.
TEST(PipeCatalogue, EveryTextureTargetMapsToItsOwnResourceTarget) {
// The compile-time half is MGPipeEveryTextureTargetIsMapped's static_assert; this is the
// same walk at runtime, so the case names the offender instead of the build naming a line.
for (SizeT i = 0; i < static_cast<SizeT>(TextureTarget::TextureTargetCount); ++i) {
const auto target = static_cast<TextureTarget>(i);
EXPECT_NE(MGPipeResourceTargetForTextureTarget(target), kMGPipeResourceTargetUnmapped)
<< "TextureTarget " << i << " has no MGPResourceDesc::Target row";
EXPECT_LT(MGPipeResourceTargetForTextureTarget(target),
static_cast<Uint32>(MGPipeResourceTarget::Count));
}
// Buffer is 0 and stays 0: P3a's constant is what a zero-initialised record already says,
// and the narrowed ack predicate below compares against it.
EXPECT_EQ(static_cast<Uint32>(MGPipeResourceTarget::Buffer), 0u);
EXPECT_EQ(kMGPipeResourceTargetBuffer, 0u);
// No texture target may collide with the buffer target, or a texture descriptor would ask
// for a synchronous acknowledgement.
for (SizeT i = 0; i < static_cast<SizeT>(TextureTarget::TextureTargetCount); ++i) {
EXPECT_NE(MGPipeResourceTargetForTextureTarget(static_cast<TextureTarget>(i)),
static_cast<Uint32>(kMGPipeResourceTargetBuffer));
}
// A rectangle texture is NOT a 2D texture on the wire. Espryt lowers both to GL_TEXTURE_2D
// at bind time and lowers Texture1D the same way, and Tex1D still has an enumerator of its
// own; folding rectangle onto Tex2D here would erase a distinction both backends switch on.
EXPECT_NE(MGPipeResourceTargetForTextureTarget(TextureTarget::Texture2D),
MGPipeResourceTargetForTextureTarget(TextureTarget::TextureRectangle));
}
// P4a, D-D3 / ID-12: MGPSubData::Target is TWO facts in one Uint16 - the low byte says which
// KIND of storage the destination is, the high byte which cube face / upload target the level
// belongs to - and the packing is the contract's, not each emitter's.
//
// The property this case exists for is the COLLISION the packing prevents.
// TextureUploadTarget::Texture1D is 0 and the applier's buffer branch tests the WHOLE field
// == 0, so a texture record carrying the bare upload enumerator would be indistinguishable
// from a buffer record exactly when its owner is a 1D texture, and that texture's upload
// would be dispatched into the buffer path. Nothing else in the tree would have said so.
TEST(PipeCatalogue, SubDataTargetPacksAResourceTargetAndAnUploadTarget) {
// Both halves must fit their byte, or the encoding is not an encoding.
static_assert(static_cast<Uint32>(MGPipeResourceTarget::Count) <= 0x100u);
static_assert(static_cast<Uint32>(TextureUploadTarget::TextureUploadTargetCount) <= 0x100u);
// 0 first, and deliberately: it is the enumerator that makes the collision possible. Then
// the plain 2D upload, the first and last cube face, and the largest enumerator the enum
// has, which is what proves the byte is wide enough in practice and not just in principle.
const Uint32 uploadTargets[] = {
0u,
static_cast<Uint32>(TextureUploadTarget::Texture2D),
static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX),
static_cast<Uint32>(TextureUploadTarget::CubeMapNegativeZ),
static_cast<Uint32>(TextureUploadTarget::TextureUploadTargetCount) - 1u,
};
for (Uint32 resource = 0; resource < static_cast<Uint32>(MGPipeResourceTarget::Count);
++resource) {
for (const Uint32 upload : uploadTargets) {
const Uint16 packed = MGPipePackSubDataTarget(resource, upload);
EXPECT_EQ(MGPipeSubDataResourceTargetOf(packed), static_cast<Uint8>(resource))
<< "resource target " << resource << " upload target " << upload;
EXPECT_EQ(MGPipeSubDataUploadTargetOf(packed), static_cast<Uint8>(upload))
<< "resource target " << resource << " upload target " << upload;
}
}
// THE BUFFER INVARIANT, at compile time in MGPipeTypes.h and again here so a failure names
// itself: a buffer record's Target is exactly kMGPipeResourceTargetBuffer, whole field,
// upload byte and all, so P3a's records are unchanged on the wire.
static_assert(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer, 0u) ==
kMGPipeResourceTargetBuffer);
EXPECT_EQ(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer, 0u), kMGPipeResourceTargetBuffer);
EXPECT_EQ(MGPipePackSubDataTarget(kMGPipeResourceTargetBuffer,
static_cast<Uint32>(TextureUploadTarget::Texture1D)),
kMGPipeResourceTargetBuffer);
MGPSubData zeroed{};
EXPECT_EQ(zeroed.Target, kMGPipeResourceTargetBuffer);
// ...and the other side of it: a 1D texture's upload target IS 0, and packed it still
// cannot be mistaken for a buffer, because no texture's resource target is 0.
EXPECT_EQ(static_cast<Uint32>(TextureUploadTarget::Texture1D), 0u);
for (Uint32 resource = 1; resource < static_cast<Uint32>(MGPipeResourceTarget::Count);
++resource) {
EXPECT_NE(MGPipePackSubDataTarget(resource, 0u), kMGPipeResourceTargetBuffer)
<< "resource target " << resource << " collides with a buffer record";
}
EXPECT_NE(MGPipePackSubDataTarget(MGPipeResourceTargetForTextureTarget(TextureTarget::Texture1D),
static_cast<Uint32>(TextureUploadTarget::Texture1D)),
kMGPipeResourceTargetBuffer);
// What a real cube-face record reads back as, through the field rather than a local.
MGPSubData record{};
record.Target =
MGPipePackSubDataTarget(MGPipeResourceTargetForTextureTarget(TextureTarget::TextureCubeMap),
static_cast<Uint32>(TextureUploadTarget::CubeMapNegativeY));
EXPECT_EQ(MGPipeSubDataResourceTargetOf(record.Target),
static_cast<Uint8>(MGPipeResourceTarget::TexCube));
EXPECT_EQ(MGPipeSubDataUploadTargetOf(record.Target),
static_cast<Uint8>(TextureUploadTarget::CubeMapNegativeY));
// Six faces share one resource target: the high byte is the only thing that tells them
// apart, which is why it cannot be dropped.
EXPECT_EQ(MGPipeSubDataResourceTargetOf(
MGPipePackSubDataTarget(static_cast<Uint32>(MGPipeResourceTarget::TexCube),
static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX))),
MGPipeSubDataResourceTargetOf(record.Target));
EXPECT_NE(MGPipeSubDataUploadTargetOf(
MGPipePackSubDataTarget(static_cast<Uint32>(MGPipeResourceTarget::TexCube),
static_cast<Uint32>(TextureUploadTarget::CubeMapPositiveX))),
MGPipeSubDataUploadTargetOf(record.Target));
}
// P4a, ID-12: the three constants MGPSurface::Kind is spelled with, the texture target the
// record grew where its Pad0 was, and MGPTextureParams::DepthStencilMode's two numbers.
//
// All three were UNSTATED in the contract and were being re-invented on both sides of the
// boundary - which is the way a wire field acquires two meanings. The values themselves are
// unremarkable; what this case pins is that there is exactly one spelling of each.
TEST(PipeCatalogue, SurfaceNamesItsKindItsTextureTargetAndItsDepthStencilAspect) {
// MGPipeKind is REUSED rather than a second three-value enum minted beside the field.
EXPECT_EQ(kMGPipeSurfaceKindNone, static_cast<Uint8>(MGPipeKind::None));
EXPECT_EQ(kMGPipeSurfaceKindTexture, static_cast<Uint8>(MGPipeKind::Texture));
EXPECT_EQ(kMGPipeSurfaceKindRenderbuffer, static_cast<Uint8>(MGPipeKind::Renderbuffer));
EXPECT_NE(kMGPipeSurfaceKindTexture, kMGPipeSurfaceKindRenderbuffer);
// None == 0 is load-bearing: it is what makes a zero-initialised record already BE the
// empty attachment point, which every emitter and every reader relies on.
EXPECT_EQ(kMGPipeSurfaceKindNone, 0u);
// Pad0 -> Uint16 TextureTarget. THE SIZE DID NOT MOVE - the two bytes were already there -
// and neither did anything in front of it.
EXPECT_EQ(sizeof(MGPSurface), 24u);
EXPECT_EQ(offsetof(MGPSurface, UploadTarget), 20u);
EXPECT_EQ(offsetof(MGPSurface, TextureTarget), 22u);
// The sentinel is TextureTarget::Unknown widened, so it is a value no real target has.
EXPECT_EQ(kMGPipeSurfaceNoTextureTarget, 0xFFFFu);
EXPECT_EQ(kMGPipeSurfaceNoTextureTarget, static_cast<Uint16>(TextureTarget::Unknown));
for (SizeT i = 0; i < static_cast<SizeT>(TextureTarget::TextureTargetCount); ++i) {
EXPECT_NE(static_cast<Uint16>(i), kMGPipeSurfaceNoTextureTarget);
}
// A ZEROED MGPSurface CARRIES TextureTarget 0, AND 0 IS TextureTarget::Texture1D, NOT THE
// SENTINEL. That is documented rather than defended, and it is why the field's contract is
// "consulted only when Kind == kMGPipeSurfaceKindTexture": a zeroed record is Kind == None
// and names no texture at all, so a reader that gates on Kind can never see the 0. A
// reader that does not gate would read Texture1D out of an empty attachment point.
MGPSurface empty{};
EXPECT_EQ(empty.TextureTarget, 0u);
EXPECT_EQ(static_cast<Uint16>(TextureTarget::Texture1D), 0u);
EXPECT_EQ(empty.Kind, kMGPipeSurfaceKindNone);
EXPECT_TRUE(MGPipeHandleIsNull(empty.Res));
// A renderbuffer point names no texture and says so with the sentinel, which is what
// distinguishes "not a texture" from "a 1D texture" for a reader that looks anyway.
MGPSurface renderbuffer{};
renderbuffer.Kind = kMGPipeSurfaceKindRenderbuffer;
renderbuffer.TextureTarget = kMGPipeSurfaceNoTextureTarget;
EXPECT_NE(renderbuffer.TextureTarget, static_cast<Uint16>(TextureTarget::Texture1D));
// The half a compiler cannot catch: the PipeFields.def row. MGPSurface still asserts its
// size whether or not the field list names TextureTarget, so a comparator blind to the
// field would pass a target-only divergence under MOBILEGL_PIPE_VERIFY - and the field is
// exactly what the four cross-object masks key on.
MGPSurface a{};
MGPSurface b{};
const char* field = nullptr;
EXPECT_TRUE(MGPipeVerify(a, b, &field));
a.TextureTarget = static_cast<Uint16>(TextureTarget::TextureCubeMap);
EXPECT_FALSE(MGPipeVerify(a, b, &field));
EXPECT_STREQ(field, "TextureTarget");
// DepthStencilMode: 0 = GL_DEPTH_COMPONENT, 1 = GL_STENCIL_INDEX. Depth is 0 because it is
// the GL initial value and a texture that never asks for the stencil aspect never emits
// the call, so a zeroed record has to decode to what an untouched texture already has.
EXPECT_EQ(kMGPipeDepthStencilModeDepth, 0u);
EXPECT_EQ(kMGPipeDepthStencilModeStencil, 1u);
EXPECT_NE(kMGPipeDepthStencilModeDepth, kMGPipeDepthStencilModeStencil);
MGPTextureParams params{};
EXPECT_EQ(params.DepthStencilMode, kMGPipeDepthStencilModeDepth);
}
// G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes.
TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) {
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::GetCaps), 1);
@@ -352,9 +594,19 @@ TEST(PipeCatalogue, FloatVectorsCompareBitwise) {
// the struct that used to memcmp is compared member by member. P3a added the two vertex wire
// views as a seventh and eighth non-payload entry (63 + 8), for the same reason: they are the
// elements of create_vertex_elements' blob, and a memcmp over that blob would false-differ on
// MGPVertexAttribWire::Pad0.
// MGPVertexAttribWire::Pad0. P4a adds SamplerParameters as a ninth (63 + 9 = 72), and the name
// of this case stays what it was, because a removed test name is a gate failure of its own.
//
// SamplerParameters IS THE SHARPEST OF THE NINE. It is 100 bytes with THREE BYTES OF TRAILING
// PADDING (96 bytes of members plus the one-byte borderColorForm), it rides
// MGPSamplerDesc::Parameters as a blob, and until P4a it had no field list and no verify-list
// row at all - so the comparator fell back to comparing the blob as BYTES and could
// false-differ on padding nobody writes. That is not a theoretical hazard for this struct:
// the client's CSO cache confirms a hash hit with a memcmp over the same bytes, so a codec or
// a cache that read the padding would mint a fresh CSO per call and the verify lane would
// abort at random.
TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
EXPECT_EQ(kMGPipeVerifiedPayloadCount, 71u);
EXPECT_EQ(kMGPipeVerifiedPayloadCount, 72u);
static_assert(MGPipeHasFieldVerifier<RenderStateParameters>::value);
static_assert(MGPipeHasFieldVerifier<PixelStoreParameters>::value);
static_assert(MGPipeHasFieldVerifier<PerBufferBlendState>::value);
@@ -363,6 +615,7 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
static_assert(MGPipeHasFieldVerifier<MGHostSpan>::value);
static_assert(MGPipeHasFieldVerifier<MGPVertexAttribWire>::value);
static_assert(MGPipeHasFieldVerifier<MGPVertexBindingPointWire>::value);
static_assert(MGPipeHasFieldVerifier<SamplerParameters>::value);
PixelStoreParameters p{};
PixelStoreParameters q{};
const char* field = nullptr;
@@ -377,6 +630,38 @@ TEST(PipeCatalogue, SixValueStructsHaveFieldLists) {
t.Offset = 8;
EXPECT_FALSE(MGPipeVerify(s, t, &field));
EXPECT_STREQ(field, "Offset");
// P4a's ninth, and its two halves. First: the comparator sees the members, INCLUDING
// borderColorForm - which is the field a backend picks glSamplerParameterIiv over fv by,
// and which no value comparison can infer because all three border representations are
// always numerically populated.
SamplerParameters left{};
SamplerParameters right{};
EXPECT_TRUE(MGPipeVerify(left, right, &field));
right.borderColorForm = BorderColorForm::Int;
EXPECT_FALSE(MGPipeVerify(left, right, &field));
EXPECT_STREQ(field, "borderColorForm");
right = left;
right.borderColorI = IntVec4{1, 0, 0, 0};
EXPECT_FALSE(MGPipeVerify(left, right, &field));
EXPECT_STREQ(field, "borderColorI");
right = left;
right.maxAnisotropy = 4.0f;
EXPECT_FALSE(MGPipeVerify(left, right, &field));
EXPECT_STREQ(field, "maxAnisotropy");
// Second, and this is the one a byte comparison gets wrong: the THREE TRAILING PADDING
// BYTES are not fields, so garbage in them cannot make two equal sampler states differ.
// Written through a byte pointer, because that is the only way to reach a byte the struct
// does not name.
static_assert(sizeof(SamplerParameters) == 100);
right = left;
auto* rightBytes = reinterpret_cast<unsigned char*>(&right);
for (SizeT i = sizeof(SamplerParameters) - 3; i < sizeof(SamplerParameters); ++i) {
rightBytes[i] = 0x5A;
}
EXPECT_TRUE(MGPipeVerify(left, right, &field))
<< "the comparator read a padding byte: field=" << (field != nullptr ? field : "(none)");
}
// G7 pins the member list the pipeline/dynamic split is derived from.
@@ -580,6 +865,158 @@ TEST(PipeCatalogue, ResourceRespecifyAcksOnlyImmutableStorage) {
mutableStore.Width = 64u * 1024u;
EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(mutableStore));
// P4a: THE TWO IDIOMS THAT MADE THE PREDICATE HAVE TO NARROW. Textures travel on the same
// resource_respecify row as buffers, and glTexStorage* sets Immutable for a real reason -
// it is a descriptor fact the backend reads - so an Immutable-only predicate would have
// started acknowledging every immutable texture allocation the moment P4a's texture family
// landed. Texture allocation is already deferred to sync time in monolith (glTexImage* and
// glTexStorage* only mark the storage dirty, and 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 one.
//
// This is the negative control for a future widening, in both directions: a predicate that
// stopped naming the buffer target would turn these two green-and-wrong.
MGPResourceDesc immutableTexture{};
immutableTexture.Immutable = 1; // glTexStorage2D
immutableTexture.Target =
static_cast<Uint8>(MGPipeResourceTargetForTextureTarget(TextureTarget::Texture2D));
immutableTexture.Width = 256;
immutableTexture.Height = 256;
immutableTexture.Levels = 9;
EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(immutableTexture));
MGPResourceDesc renderbuffer{};
renderbuffer.Immutable = 1; // glRenderbufferStorage: one shot, and still lazy in the backend
renderbuffer.Target = static_cast<Uint8>(MGPipeResourceTarget::Renderbuffer);
renderbuffer.Width = 1920;
renderbuffer.Height = 1080;
EXPECT_FALSE(MGPipeResourceRespecifyNeedsAck(renderbuffer));
// And the buffer half still answers true with the target spelled explicitly rather than
// relying on a zero-initialised record to mean "buffer".
MGPResourceDesc immutableBuffer{};
immutableBuffer.Immutable = 1;
immutableBuffer.Target = kMGPipeResourceTargetBuffer;
EXPECT_TRUE(MGPipeResourceRespecifyNeedsAck(immutableBuffer));
// And the opcode did not move: a flag-word edit is not a catalogue edit.
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::ResourceRespecify), 3);
// P4a, ID-18 M4. The metadata-update rule is a PROSE contract stated beside the predicate
// above - it compares an incoming descriptor against the applier's stored one, which this
// header cannot do - so what is pinnable here is the thing that would make the prose lie:
// a field added to MGPResourceDesc and classified into neither list. The size is the
// tripwire, and the two metadata fields are named so the classification cannot be lost to
// a rename either.
EXPECT_EQ(sizeof(MGPResourceDesc), 88u);
EXPECT_EQ(sizeof(MGPResourceDesc::BindMask), 2u);
EXPECT_EQ(sizeof(MGPResourceDesc::ImageBindableHint), 1u);
// HasDefinedContent sits next to ImageBindableHint and is deliberately on the OTHER side
// of the line: glBufferData(size, NULL) at an unchanged size is an orphaning
// reallocation, so a record that moves only it must still clear, and must never be read
// as a mask change.
EXPECT_NE(offsetof(MGPResourceDesc, HasDefinedContent),
offsetof(MGPResourceDesc, ImageBindableHint));
}
// G13b, D-M: "emulation 在 split 下显式 Fatal 直到 P8" costs P4a a NAMED, GREPPABLE call site
// per unmigrated emulation and nothing else - in monolith MGPipeUnmigratedEmulation is a no-op
// and the emulation still runs on exactly the code path it runs on today. What this pins is
// the LIST, because the whole value of the mechanism is that P5 and P8 edit one function
// instead of rediscovering five call sites, and a site that quietly disappears has to be a red
// gate rather than a surprise three phases later.
//
// The names are pinned here rather than counted in the backend, because the count alone cannot
// say WHICH one was lost. The purity gate greps the count; this says what the count is of.
TEST(PipeCatalogue, EveryUnmigratedEmulationIsNamedOnce) {
// Every one of these is an emulation that reads or writes CLIENT memory a split server
// would not have: a CPU shadow mirror, a CPU mipmap fallback, a shadow-conversion readback,
// and the re-dirty of already-uploaded levels that a texture re-mint performs.
const char* const kNames[] = {
"copy-image-shadow-mirror", // the glCopyImageSubData CPU-shadow mirror
"generate-mipmap-storage", // EnsureGenerateMipmapStorageAllocated
"generate-mipmap-cpu-fallback", // GenerateThreeChannelFloatMipmapOnCpu
"get-tex-image-shadow", // GetTexImageViaShadowConversion
"texture-remint-pull", // RequireImageBindableStorage's re-dirty
};
EXPECT_EQ(std::size(kNames), 5u);
// No duplicates: two sites sharing a name would make the grepped count and this list
// disagree in the one direction nobody would notice.
for (SizeT i = 0; i < std::size(kNames); ++i) {
for (SizeT j = i + 1; j < std::size(kNames); ++j) {
EXPECT_STRNE(kNames[i], kNames[j]);
}
}
// The last one is the head of the only NEW stall class the design admits, and P4a supplies
// exactly one of its four mitigations - prevention, through ImageBindableHint on every
// create and respecify. The async pull, the bounded retention and the
// ResourceSubDataComplete terminator are a later phase's, and P4a must not build half a
// terminator.
EXPECT_STREQ(kNames[4], "texture-remint-pull");
#if MOBILEGL_PIPE_PUSH
// In monolith it really is a no-op: calling it changes nothing and returns nothing. The
// teeth are a split server's, and the call site is what P8 gives them to.
for (const char* name : kNames) MGPipeUnmigratedEmulation(name);
#endif
}
// THE ShaderCso COMPOSITE BAND IS A SECOND SPACE, AND THE ALLOCATOR REPORTS IT SEPARATELY.
//
// The band's base is 983040, so a composite handle passes every bound an ordinary one does and
// a slot-indexed table that forgets the band allocates ~983k entries for one program pipeline.
// That is why the allocator keeps two dense tables - and it is also why the two must be
// COUNTED apart: a high-water mark that folded them would be 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 ShaderCso slots - the shape that catches a dense table that never
// shrinks, i.e. the ~1.3 KB-per-record leak the P3a final review found - would be vacuously
// true for the rest of the process. One merged number is one real assertion and one that
// cannot go red; two numbers are two real assertions, which is what the per-kind leak cases
// need.
//
// This case pins both halves: a leaked COMPOSITE moves the band's marks and not the ordinary
// one, and an ordinary leak still moves the ordinary mark with a composite outstanding.
TEST(PipeCatalogue, TheCompositeShaderBandIsCountedApartFromTheOrdinarySpace) {
#if MOBILEGL_PIPE_PUSH
MGPipeSlotAllocator slots;
const Uint32 ordinaryBefore = slots.HighWater(MGPipeKind::ShaderCso);
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase)
<< "the band's high-water mark starts at its base, so it is monotone from the first mint";
EXPECT_EQ(slots.CompositeLiveCount(), 0u);
EXPECT_EQ(slots.CompositeFreeCount(), 0u);
// A COMPOSITE MOVES THE BAND'S MARKS AND ONLY THOSE.
const MGPipeHandle composite = slots.AllocateComposite(9001);
ASSERT_FALSE(MGPipeHandleIsNull(composite));
ASSERT_TRUE(MGPipeIsCompositeShaderSlot(composite.Slot));
EXPECT_EQ(slots.HighWater(MGPipeKind::ShaderCso), ordinaryBefore)
<< "a composite mint moved the ORDINARY high-water mark, so the ordinary space's leak "
"assertion is vacuous from here on";
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase + 1u);
EXPECT_EQ(slots.CompositeLiveCount(), 1u);
// A live composite IS a live ShaderCso: the merged count is deliberate and stays.
EXPECT_EQ(slots.LiveCount(MGPipeKind::ShaderCso), 1u);
// AND THE ORDINARY MARK STILL MOVES WITH A COMPOSITE OUTSTANDING - the half that stopped
// existing when one number carried both spaces.
const MGPipeHandle ordinary = slots.Allocate(MGPipeKind::ShaderCso);
ASSERT_FALSE(MGPipeHandleIsNull(ordinary));
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(ordinary.Slot));
EXPECT_GT(slots.HighWater(MGPipeKind::ShaderCso), ordinaryBefore);
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase + 1u)
<< "an ordinary mint moved the BAND's high-water mark";
// The slot goes back to the BAND's free list, and the high-water marks do not come back
// down - which is exactly what makes them a leak witness rather than a live count.
const Uint32 ordinaryHighWater = slots.HighWater(MGPipeKind::ShaderCso);
slots.Free(MGPipeKind::ShaderCso, composite);
EXPECT_EQ(slots.CompositeLiveCount(), 0u);
EXPECT_EQ(slots.CompositeFreeCount(), 1u);
EXPECT_EQ(slots.FreeCount(MGPipeKind::ShaderCso), 1u);
EXPECT_EQ(slots.CompositeHighWater(), kMGPipeShaderCsoCompositeSlotBase + 1u);
EXPECT_EQ(slots.HighWater(MGPipeKind::ShaderCso), ordinaryHighWater);
EXPECT_EQ(slots.LiveCount(MGPipeKind::ShaderCso), 1u);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client slot allocator in a pull build";
#endif
}
+827
View File
@@ -0,0 +1,827 @@
// MobileGL - MobileGL/MG_Test/Pipe/ProgramEmitTest.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
// P4a's program family on the client: create/bind/delete_shader_state, set_draw_program,
// set_dispatch_program and set_global_constants.
//
// THE TWO PROPERTIES THIS SUITE EXISTS FOR, and both are invisible from the emitted bytes:
// * THE STAGE MASK COMES FROM THE LINKED SNAPSHOT, never from the live attach list.
// glAttachShader and glCompileShader take effect only at the NEXT link and neither moves
// the link version, so a descriptor built from the attach list describes a program that
// does not exist yet - and it would agree with nothing, because the SPIR-V array beside it
// is indexed by the snapshot.
// * THE EMITTER JOINS AND THE TRACKER DOES NOT. Bit 6's shutter reads GetCurrentProgram()
// deliberately and not GetProgramForDraw(), because the tracker must not force a compile
// just to answer "did the shader move"; the join belongs to the emitter, which makes the
// same call the verb is about to make anyway.
//
// THE SUITE IS `ProgramEmit`, not `ProgramEmitTest`: the file is XTest.cpp and the suite is X.
//
// THE TARGET AND ITS ctest REGISTRATION ARE THE CONTRACT COMMIT'S; THE CONTENTS ARE NOT.
//
// IT HAS ITS OWN main() for ResourceEmitTest's reason. Every case is a visible SKIP in a pull
// build rather than a vanishing test, so `ctest -N` stays name-for-name identical between the
// pull and the push trees.
#include <gtest/gtest.h>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#if defined(_WIN32)
#include <process.h>
#define MGTEST_HAVE_FORK 0
#else
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
#define MGTEST_HAVE_FORK 1
#endif
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.h"
// MOBILEGL_PIPE_POISON is DERIVED in the header below (PipeInputs.h:20-26) and nowhere
// else, so a TU that tests it without this include silently reads it as 0. That is
// invisible in a push build (where it really is 0) and in a verify build (where
// -DMOBILEGL_PIPE_VERIFY=1 is on the command line); MOBILEGL_BUILD_DISAGGREGATED is the
// one arming condition that lives behind the header, so a split build is the first place
// the refusals below stop being fatal while the expectations still say they are.
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/Pipe/ProgramEmit.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/Tracker.h>
#include <MG_Pipe/PipeApply.h>
// The applier takes the two artefact structs BY POINTER beside the record, so a case that
// drives create_shader_state needs their definitions - the applier's own header deliberately
// only forward-declares them.
#include <MG_State/GLState/ProgramState/ProgramArtifacts.h>
#include <MG_State/GLState/Core.h>
#endif
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
String g_logPath;
int ProcessId() {
#if defined(_WIN32)
return _getpid();
#else
return static_cast<int>(getpid());
#endif
}
#if MOBILEGL_PIPE_PUSH
// `from` is a byte offset, and it exists because of the fork below: the library's log file
// is already open by the time a case runs, so the child's lines are APPENDED to it rather
// than written to a fresh file, and only what the child appended is this drive's evidence.
std::string ReadLog(std::streamoff from = 0) {
std::ifstream in(g_logPath, std::ios::binary);
if (from > 0) in.seekg(from, std::ios::beg);
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
// Where the library's log file currently ends. Reading from here after the child has
// aborted gives exactly the lines that drive produced.
std::streamoff LogEnd() {
std::ifstream in(g_logPath, std::ios::binary | std::ios::ate);
return in ? static_cast<std::streamoff>(in.tellg()) : std::streamoff{0};
}
// A fresh applier per case, BOTH SCOPES, and it takes both because there are two: a reset
// is a make-current and deliberately KEEPS the object records, so a fixture that wants a
// genuinely empty applier has to say the other one as well. Every case is its own process
// under ctest, so this is belt and braces - but running the binary by hand must give the
// same answers as running it under ctest.
struct ApplierGuard {
ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~ApplierGuard() {
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
};
#if MGTEST_HAVE_FORK
struct ChildResult {
int Status = -1;
std::string Log;
};
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
// THE LOG PATH IS NOT UNLINKED HERE, and that is what this helper had to learn when the
// client's cases landed in the same file as the applier's: main() calls
// MobileGL::Initialize(), so the library's log FILE* is already open on this path and
// fork() duplicates it. Removing the path would leave the child writing into a deleted
// inode and the parent reading an empty file - the child would still abort, and the
// assertion on WHAT it named could never see the line. So the log's end is remembered
// and only what the child appended is read back.
const std::streamoff before = LogEnd();
std::fflush(nullptr);
const pid_t pid = ::fork();
if (pid < 0) return result;
if (pid == 0) {
body();
::_exit(0);
}
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog(before);
return result;
}
Bool DiedOfAbort(const ChildResult& r) { return WIFSIGNALED(r.Status) && WTERMSIG(r.Status) == SIGABRT; }
std::string DescribeStatus(const ChildResult& r) {
if (r.Status < 0) return "fork/waitpid failed";
if (WIFEXITED(r.Status)) return "exited " + std::to_string(WEXITSTATUS(r.Status));
if (WIFSIGNALED(r.Status)) return "signal " + std::to_string(WTERMSIG(r.Status));
return "status " + std::to_string(r.Status);
}
#endif // MGTEST_HAVE_FORK
// Drives a call a trip wire must REFUSE, and asserts the wire NAMED what it refused. The
// two arms differ by design: a poison or verify build stops the process, so the drive is a
// forked child and the parent reads SIGABRT plus the line out of the log; a shipped push
// build logs and carries on from a defined state, so there the line is read back in process
// and the caller goes on to assert that nothing moved.
template <class Body>
void ExpectRefusedNaming(const char* needle, Body body) {
#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY
#if MGTEST_HAVE_FORK
const std::string tagged = std::string("Fatal{ProtocolCorruption} ") + needle;
const ChildResult child = RunInChild(body);
EXPECT_TRUE(DiedOfAbort(child)) << DescribeStatus(child) << "; log: " << child.Log;
EXPECT_NE(child.Log.find(tagged), std::string::npos)
<< "the gate fired without naming what it refused; wanted \"" << tagged << "\"; log: " << child.Log;
#else
(void)needle;
(void)body; // no fork on this platform; the verdict here is std::abort()
#endif
#else
const std::string tagged = std::string("ProtocolCorruption ") + needle;
const std::string before = ReadLog();
body();
EXPECT_NE(ReadLog().substr(before.size()).find(tagged), std::string::npos)
<< "the gate refused without saying what it refused; wanted \"" << tagged << "\"";
#endif
}
#endif // MOBILEGL_PIPE_PUSH
} // namespace
// See FramebufferEmitTest's twin for why this is a shape pin rather than a placeholder.
TEST(ProgramEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#if MOBILEGL_PIPE_PUSH
EXPECT_EQ(&MGPipeProgramEmitterInstance(), &MGPipeProgramEmitterInstance());
EXPECT_TRUE(kMGPipeWiredProgramSubsystem == 0 ||
kMGPipeWiredProgramSubsystem == kMGPipeSubsystemPrograms);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build";
#endif
}
// =========================================================================================
// The APPLIER's half of the program family (the wire commits'): the shader CSO record, the
// three bindings and the default uniform block. The emitter's half - the join at the validate
// point, the never-uploaded sentinel that must never be emitted, the composite resolver - is
// the client package's and lands beside these.
// =========================================================================================
#if MOBILEGL_PIPE_PUSH
namespace {
using MG_State::GLState::LinkArtifacts;
using MG_State::GLState::SpirvArtifacts;
MGPProgramDesc ProgramDesc(MGPipeHandle cso, Uint32 stageMask, Uint32 globalUboSize) {
MGPProgramDesc desc{};
desc.Cso = cso;
desc.StageMask = stageMask;
desc.GlobalUboSize = globalUboSize;
desc.ReservedNumSamplesOffset = 32;
desc.SpirvStatus = 1;
desc.NativeFloat64 = 1;
desc.PointSizeDemoted = 1;
desc.EnableSpirvValidation = 1;
// ALL SEVEN BLOB REFS ARE DECLARED WITH Size 0 - "this record does not declare its
// blob" - which is exactly what a monolith emission is: the artefacts ride beside the
// record through the two companion pointers and the codec is never called.
return desc;
}
MGPHandleOnly ProgramHandle(MGPipeHandle cso) {
return MGPHandleOnly{cso, static_cast<Uint32>(MGPipeKind::ShaderCso), 0};
}
MGPGlobalConstants GlobalConstants(MGPipeHandle cso, Uint32 version) {
MGPGlobalConstants record{};
record.ShaderCso = cso;
record.Version = version;
return record;
}
const MGPipeShaderCsoRecord& ProgramRecordOf(Uint32 slot) {
EXPECT_GT(MGPipeApplier().ShaderCsos.size(), static_cast<SizeT>(slot));
return MGPipeApplier().ShaderCsos[slot];
}
} // namespace
#endif
// A create starts the record over and leaves Serial at 0; a RE-ISSUE on the same handle is how
// a relink travels, and it takes the default uniform block with it - a block sized to a layout
// that no longer exists is worse than no block, and the sentinel is the value that says
// "nothing has been uploaded for this program".
TEST(ProgramEmit, ACreateStoresTheDescriptorAndARelinkCountsUpAndDropsTheBlockKeyedToTheOldLayout) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{5, 2};
const Uint8 block[64] = {};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 64), &link, &spirv);
EXPECT_TRUE(ProgramRecordOf(5).Live);
EXPECT_EQ(ProgramRecordOf(5).Gen, 2u);
EXPECT_EQ(ProgramRecordOf(5).Serial, 0u) << "a create is not a mutation";
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x3u);
EXPECT_EQ(ProgramRecordOf(5).Desc.GlobalUboSize, 64u);
EXPECT_EQ(ProgramRecordOf(5).Desc.ReservedNumSamplesOffset, 32u);
EXPECT_EQ(ProgramRecordOf(5).Desc.NativeFloat64, 1u);
EXPECT_EQ(ProgramRecordOf(5).GlobalConstantsVersion, ~Uint32{0})
<< "a fresh record starts at the never-uploaded sentinel";
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 7), block);
ASSERT_EQ(ProgramRecordOf(5).GlobalConstants.size(), 64u);
const Uint64 blockSerial = ProgramRecordOf(5).GlobalConstantsSerial;
// The relink.
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x7u, 32), &link, &spirv);
EXPECT_EQ(ProgramRecordOf(5).Serial, 1u);
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x7u);
EXPECT_TRUE(ProgramRecordOf(5).GlobalConstants.empty())
<< "a block sized to the layout the relink replaced survived it";
EXPECT_EQ(ProgramRecordOf(5).GlobalConstantsVersion, ~Uint32{0});
EXPECT_GT(ProgramRecordOf(5).GlobalConstantsSerial, blockSerial)
<< "the clearing was not announced, so a twin can still match what it uploaded before";
// A RECYCLED SLOT STARTS OVER: inheriting one field of the previous occupant is how a
// program at a recycled slot inherits its predecessor's reflection.
MGPipeApplyCreateShaderState(ProgramDesc(MGPipeHandle{5, 3}, 0x1u, 16), &link, &spirv);
EXPECT_EQ(ProgramRecordOf(5).Gen, 3u);
EXPECT_EQ(ProgramRecordOf(5).Serial, 0u) << "a recycled slot kept its predecessor's serial";
EXPECT_EQ(ProgramRecordOf(5).Desc.StageMask, 0x1u);
#endif
}
// The three refusals a create can produce: no artefacts at all behind seven undeclared blobs, a
// default uniform block no program can have, and a slot outside the record table's bound.
TEST(ProgramEmit, ACreateWithNoArtefactsAnOversizedBlockOrACorruptSlotIsRefusedNamingTheProgram) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPProgramDesc desc = ProgramDesc(MGPipeHandle{4, 1}, 0x3u, 0);
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the record declares no blobs and carries no "
"artefacts",
[&desc, &spirv]() { MGPipeApplyCreateShaderState(desc, nullptr, &spirv); });
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the record declares no blobs and carries no "
"artefacts",
[&desc, &link]() { MGPipeApplyCreateShaderState(desc, &link, nullptr); });
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
const MGPProgramDesc huge = ProgramDesc(MGPipeHandle{4, 1}, 0x3u, kMGPipeMaxGlobalConstantsBytes + 1);
ExpectRefusedNaming("create_shader_state {slot=4, gen=1}: the default uniform block is larger than any "
"program may declare",
[&huge, &link, &spirv]() { MGPipeApplyCreateShaderState(huge, &link, &spirv); });
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty()) << "the table was grown by a refused record";
const MGPProgramDesc pastTheBound = ProgramDesc(MGPipeHandle{kMGPipeMaxShaderCsoSlots, 1}, 0x3u, 0);
ExpectRefusedNaming("create_shader_state {slot=1048576, gen=1}: the slot is outside the record table's "
"bound",
[&pastTheBound, &link, &spirv]() {
MGPipeApplyCreateShaderState(pastTheBound, &link, &spirv);
});
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
EXPECT_TRUE(MGPipeApplier().CompositeShaderCsos.empty());
#endif
}
// Three bindings, one serial, and each of them follows its OWN handle: set_draw_program and
// set_dispatch_program are two calls because the frontend has two joins. A null handle is legal
// and means "nothing bound"; a dead one leaves the previous binding standing and is counted.
TEST(ProgramEmit, TheThreeBindingsFollowTheirOwnHandleAndADeadOneLeavesThePreviousBindingStanding) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle draw{2, 1};
const MGPipeHandle dispatch{3, 1};
MGPipeApplyCreateShaderState(ProgramDesc(draw, 0x3u, 0), &link, &spirv);
MGPipeApplyCreateShaderState(ProgramDesc(dispatch, 0x20u, 0), &link, &spirv);
const Uint64 serialBefore = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyBindShaderState(ProgramHandle(draw));
MGPipeApplySetDrawProgram(ProgramHandle(draw));
MGPipeApplySetDispatchProgram(ProgramHandle(dispatch));
EXPECT_EQ(MGPipeApplier().BoundShaderCso, draw);
EXPECT_EQ(MGPipeApplier().DrawProgram, draw);
EXPECT_EQ(MGPipeApplier().DispatchProgram, dispatch);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 3);
// A dead handle: previous binding untouched, and COUNTED - a no-op nobody can see is a
// dropped bind nobody can see.
const Uint64 refusedBefore = MGPipeApplier().RefusedObjectCalls;
MGPipeApplySetDrawProgram(ProgramHandle(MGPipeHandle{2, 9}));
MGPipeApplyBindShaderState(ProgramHandle(MGPipeHandle{99, 1}));
MGPipeApplySetDispatchProgram(ProgramHandle(MGPipeHandle{3, 9}));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 3);
EXPECT_EQ(MGPipeApplier().DrawProgram, draw);
EXPECT_EQ(MGPipeApplier().BoundShaderCso, draw);
EXPECT_EQ(MGPipeApplier().DispatchProgram, dispatch);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 3) << "a refused bind moved the serial";
// The null handle is a state, not an error.
MGPipeApplySetDrawProgram(ProgramHandle(kMGPipeNullHandle));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialBefore + 4);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, refusedBefore + 3) << "a null bind was counted as a refusal";
#endif
}
// A delete drops the record whole, keeps the generation, and clears EVERY binding that named
// it - unlike the unit sets, which are "the last set as received". A binding left pointing at a
// dropped record would make the next verb refuse a state the applier itself created.
TEST(ProgramEmit, ADeleteDropsTheRecordAndClearsEveryBindingThatNamedIt) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{6, 4};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 0), &link, &spirv);
MGPipeApplyBindShaderState(ProgramHandle(cso));
MGPipeApplySetDrawProgram(ProgramHandle(cso));
MGPipeApplySetDispatchProgram(ProgramHandle(cso));
const Uint64 serialBefore = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(cso));
EXPECT_FALSE(ProgramRecordOf(6).Live);
EXPECT_EQ(ProgramRecordOf(6).Gen, 4u) << "a destroy keeps the generation";
EXPECT_EQ(ProgramRecordOf(6).Desc.StageMask, 0u)
<< "a stale read of a deleted slot must find nothing, not the program that used to be there";
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderCso));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DispatchProgram));
EXPECT_GT(MGPipeApplier().ProgramBindingSerial, serialBefore);
// THE SECOND NOTICE IS A REFUSED NO-OP. A composite's slot has two independent release
// paths and both arrive here; the second finding nothing is what makes the double free
// proven rather than assumed.
const Uint64 serialAfter = MGPipeApplier().ProgramBindingSerial;
MGPipeApplyDeleteShaderState(ProgramHandle(cso));
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
EXPECT_EQ(MGPipeApplier().ProgramBindingSerial, serialAfter);
#endif
}
// The default uniform block lands on the PROGRAM's record - it is (ShaderCso, Version) keyed
// and belongs to the program, not to the context that uploaded it - and the length it is held
// to is the program's own GlobalUboSize, which the create already bounded.
TEST(ProgramEmit, TheDefaultUniformBlockLandsOnTheProgramsRecordAndTheSentinelIsRefused) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{7, 1};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 8), &link, &spirv);
Uint8 block[8] = {1, 2, 3, 4, 5, 6, 7, 8};
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 11), block);
ASSERT_EQ(ProgramRecordOf(7).GlobalConstants.size(), 8u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstants[7], 8u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 11u);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsSerial, 1u);
EXPECT_EQ(ProgramRecordOf(7).Serial, 0u) << "a block upload is not a relink";
// A DECLARED blob length that agrees is fine; one that does not is refused, and so is the
// sentinel the backends read as "never uploaded".
MGPGlobalConstants declared = GlobalConstants(cso, 12);
declared.Blob.Size = 8;
MGPipeApplySetGlobalConstants(declared, block);
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 12u);
MGPGlobalConstants lying = GlobalConstants(cso, 13);
lying.Blob.Size = 9;
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: the declared blob length is not the "
"program's own default uniform block size",
[&lying, &block]() { MGPipeApplySetGlobalConstants(lying, block); });
const MGPGlobalConstants sentinel = GlobalConstants(cso, ~Uint32{0});
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: the version is the backends' "
"never-uploaded sentinel",
[&sentinel, &block]() { MGPipeApplySetGlobalConstants(sentinel, block); });
const MGPGlobalConstants noBytes = GlobalConstants(cso, 14);
ExpectRefusedNaming("set_global_constants {slot=7, gen=1}: a non-empty block carries no bytes",
[&noBytes]() { MGPipeApplySetGlobalConstants(noBytes, nullptr); });
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsVersion, 12u) << "a refused block was stored anyway";
EXPECT_EQ(ProgramRecordOf(7).GlobalConstantsSerial, 2u);
// And a block for a program this applier does not have is the ordinary counted refusal.
MGPipeApplySetGlobalConstants(GlobalConstants(MGPipeHandle{7, 2}, 15), block);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 1u);
#endif
}
// D-J4 for this family: the program record is share-group state and survives a make-current -
// re-emitting create_shader_state for a record the applier still holds would move its serial
// for nothing - while the three bindings are working state and do not.
TEST(ProgramEmit, TheProgramRecordSurvivesAMakeCurrentWhileTheThreeBindingsDoNot) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const LinkArtifacts link;
const SpirvArtifacts spirv;
const MGPipeHandle cso{8, 1};
const Uint8 block[4] = {9, 9, 9, 9};
MGPipeApplyCreateShaderState(ProgramDesc(cso, 0x3u, 4), &link, &spirv);
MGPipeApplySetGlobalConstants(GlobalConstants(cso, 21), block);
MGPipeApplyBindShaderState(ProgramHandle(cso));
MGPipeApplySetDrawProgram(ProgramHandle(cso));
const Uint64 bindingSerial = MGPipeApplier().ProgramBindingSerial;
MGPipeApplierReset();
ASSERT_TRUE(ProgramRecordOf(8).Live) << "a make-current dropped a share-group program record";
EXPECT_EQ(ProgramRecordOf(8).GlobalConstantsVersion, 21u);
EXPECT_EQ(ProgramRecordOf(8).GlobalConstants.size(), 4u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderCso));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_GT(MGPipeApplier().ProgramBindingSerial, bindingSerial)
<< "the binding serial was carried over or restarted rather than advanced";
// The bind that follows the switch still resolves, which is the whole point of the rule.
MGPipeApplySetDrawProgram(ProgramHandle(cso));
EXPECT_EQ(MGPipeApplier().DrawProgram, cso);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
MGPipeApplierReleaseObjectRecords();
EXPECT_TRUE(MGPipeApplier().ShaderCsos.empty());
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 requires the pull and push ctest name sets to be identical, name for name.
#define MGL_PROGRAM_EMIT_TEST_LIST(X) \
X(ProgramEmit, TheStageMaskComesFromTheLinkedSnapshotAndNotTheAttachList) \
X(ProgramEmit, TheNeverUploadedSentinelIsNeverEmitted) \
X(ProgramEmit, TheEmitterJoinsAndTheTrackerDoesNot) \
X(ProgramEmit, AReLinkReIssuesOnTheSameHandle) \
X(ProgramEmit, TheDrawAndDispatchProgramsAreTwoIndependentSlots) \
X(ProgramEmit, AnUnchangedProgramEmitsNothingAtAll) \
X(ProgramEmit, AReIssuedCreateReSendsTheDefaultUniformBlock) \
X(ProgramEmit, ADeadProgramsRecordLatchIsRetiredAtItsDeath)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_PROGRAM_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
namespace GL = MobileGL::MG_Impl::GLImpl;
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::ProgramObject;
struct EmitterScope {
EmitterScope() { Clear(); }
~EmitterScope() {
GL::UseProgram(0);
Clear();
}
EmitterScope(const EmitterScope&) = delete;
EmitterScope& operator=(const EmitterScope&) = delete;
static void Clear() {
MGPipeProgramEmitterInstance().Reset();
MGPipeProgramEmitterInstance().ResetCounters();
}
};
GLContext& Ctx() { return *MG_State::pGLContext; }
MGPipeProgramEmitter& Emitter() { return MGPipeProgramEmitterInstance(); }
const char* kVs = R"(#version 430 core
uniform vec4 u_value;
void main() { gl_Position = u_value; }
)";
const char* kFs = R"(#version 430 core
out vec4 o_color;
void main() { o_color = vec4(1.0); }
)";
const char* kGs = R"(#version 430 core
layout(points) in;
layout(points, max_vertices = 1) out;
void main() { gl_Position = vec4(0.0); EmitVertex(); }
)";
GLuint MakeShader(GLenum stage, const char* source) {
const GLuint shader = GL::CreateShader(stage);
GL::ShaderSource(shader, 1, &source, nullptr);
GL::CompileShader(shader);
return shader;
}
GLuint MakeVsFsProgram() {
const GLuint program = GL::CreateProgram();
GL::AttachShader(program, MakeShader(GL_VERTEX_SHADER, kVs));
GL::AttachShader(program, MakeShader(GL_FRAGMENT_SHADER, kFs));
GL::LinkProgram(program);
GLint linked = GL_FALSE;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
EXPECT_EQ(linked, GL_TRUE) << "the vertex/fragment program did not link";
return program;
}
constexpr Uint32 StageBit(ShaderStage stage) { return Uint32{1} << static_cast<Uint32>(stage); }
// ProgramObject.h says it in as many words and this is the case that holds it: a stage mask
// built from the ATTACH list would describe a program that does not exist yet, because
// glAttachShader takes effect only at the next link and does not move the link version.
// The SPIR-V array beside the mask is indexed by the same snapshot, so the two halves of
// the descriptor agree by construction rather than by care.
TEST(ProgramEmit, TheStageMaskComesFromTheLinkedSnapshotAndNotTheAttachList) {
EmitterScope scope;
const GLuint name = MakeVsFsProgram();
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(name);
ASSERT_TRUE(program);
const Uint32 linkedMask = MGPipeStageMaskOf(*program);
EXPECT_EQ(linkedMask, StageBit(ShaderStage::Vertex) | StageBit(ShaderStage::Fragment));
// A third stage is attached and NOT linked. The live attach list now has three
// shaders; the mask must not move.
GL::AttachShader(name, MakeShader(GL_GEOMETRY_SHADER, kGs));
EXPECT_EQ(program->GetAttachedShaders().size(), 3u) << "the attach really has to land";
EXPECT_EQ(MGPipeStageMaskOf(*program), linkedMask)
<< "glAttachShader takes effect at the NEXT link and moves no link version";
GL::LinkProgram(name);
GLint linked = GL_FALSE;
GL::GetProgramiv(name, GL_LINK_STATUS, &linked);
if (linked == GL_TRUE) {
EXPECT_EQ(MGPipeStageMaskOf(*program), linkedMask | StageBit(ShaderStage::Geometry))
<< "and after the relink the snapshot really does carry it";
}
}
// D-H6. ~0u is the backends' "never uploaded" sentinel and the frontend's own wrap skips
// it; the client must never put it on the wire either, or a server would read its own
// record as "nothing has ever been uploaded here" and re-upload for ever.
//
// PINNED AS A PREDICATE rather than by driving the counter to ~0u, and the reason is
// written down instead of hidden: reaching that value takes four billion
// MarkUBOContentDirty calls, which is not a test. The predicate is the thing
// EmitGlobalConstants consults, so pinning it pins the behaviour, and a deletion of the
// guard is a compile error here.
TEST(ProgramEmit, TheNeverUploadedSentinelIsNeverEmitted) {
EmitterScope scope;
EXPECT_EQ(kMGPipeGlobalConstantsNeverUploaded, ~Uint32{0});
EXPECT_FALSE(MGPipeGlobalConstantsVersionIsEmittable(kMGPipeGlobalConstantsNeverUploaded));
EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(0u));
EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(1u));
EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(~Uint32{0} - 1u));
// And the live path never produces it either: whatever the frontend's counter is at,
// the record the emitter last built carries an emittable version.
const GLuint name = MakeVsFsProgram();
GL::UseProgram(name);
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(name);
ASSERT_TRUE(program);
program->MarkUBOContentDirty();
if (Emitter().EmitGlobalConstants(Ctx()) > 0u) {
EXPECT_TRUE(MGPipeGlobalConstantsVersionIsEmittable(Emitter().LastGlobalConstants().Version));
EXPECT_EQ(Emitter().LastGlobalConstants().Version, program->GetUBOContentVersion());
EXPECT_EQ(Emitter().LastGlobalConstants().Blob.Size, 0u)
<< "the one Blob rule: a monolith emission does not declare its blob";
}
}
// D-H4, and it is a statement about the TRACKER as much as about the emitter: Update() may
// not move a program's link completeness in either direction, because answering "did the
// shader move" from a version counter is what keeps a compile off the dirty walk. The
// emitter is where the join belongs, and it is the same GetProgramForDraw() the verb is
// about to make anyway.
TEST(ProgramEmit, TheEmitterJoinsAndTheTrackerDoesNot) {
EmitterScope scope;
const GLuint name = MakeVsFsProgram();
GL::UseProgram(name);
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(name);
ASSERT_TRUE(program);
const Bool completeBefore = program->IsLinkComplete();
MGPipeTrackerInstance().Update(Ctx(), MGPipeVerbClass::kDraw);
EXPECT_EQ(program->IsLinkComplete(), completeBefore)
<< "the dirty walk must not force a compile, in either direction";
Emitter().EmitShaderState(Ctx());
EXPECT_TRUE(program->IsLinkComplete()) << "the emitter joins, because the verb would";
MGPipeTrackerInstance().Reset();
}
TEST(ProgramEmit, AReLinkReIssuesOnTheSameHandle) {
EmitterScope scope;
const GLuint name = MakeVsFsProgram();
GL::UseProgram(name);
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(name);
ASSERT_TRUE(program);
ASSERT_GT(Emitter().EmitShaderState(Ctx()), 0u);
ASSERT_EQ(Emitter().CreateCount(), 1u);
const MGPipeHandle cso = Emitter().LastProgramDesc().Cso;
EXPECT_FALSE(MGPipeHandleIsNull(cso));
EXPECT_EQ(Emitter().DrawCso(), cso);
// Nothing moved: no second record, no second bind.
Emitter().EmitShaderState(Ctx());
EXPECT_EQ(Emitter().CreateCount(), 1u);
EXPECT_EQ(Emitter().BindCount(), 1u);
const Uint32 linkVersionBefore = program->GetLinkVersion();
GL::LinkProgram(name);
ASSERT_NE(program->GetLinkVersion(), linkVersionBefore) << "the relink really has to move it";
Emitter().EmitShaderState(Ctx());
EXPECT_EQ(Emitter().CreateCount(), 2u);
// THE SAME HANDLE. Gen increments only on slot reuse and never on a respecify, so a
// relinked program is the same GL object and the server's twin table must not be asked
// to mint a second identity for it.
EXPECT_EQ(Emitter().LastProgramDesc().Cso, cso);
EXPECT_EQ(Emitter().BindCount(), 1u) << "and a re-issue on the bound handle is not a rebind";
}
// Two calls because the frontend has two joins and two PipeInputs slots. With a plain
// glUseProgram they name one object, and the record has to say so rather than leaving the
// server to guess which of the two a verb meant.
TEST(ProgramEmit, TheDrawAndDispatchProgramsAreTwoIndependentSlots) {
EmitterScope scope;
const GLuint name = MakeVsFsProgram();
GL::UseProgram(name);
ASSERT_GT(Emitter().EmitShaderState(Ctx()), 0u);
EXPECT_EQ(Emitter().DrawCso(), Emitter().DispatchCso());
EXPECT_EQ(Emitter().BoundCso(), Emitter().DrawCso());
EXPECT_EQ(Emitter().DrawProgramSetCount(), 1u);
EXPECT_EQ(Emitter().DispatchProgramSetCount(), 1u);
// A null handle is legal and means exactly "nothing bound".
GL::UseProgram(0);
Emitter().EmitShaderState(Ctx());
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().DrawCso()));
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().DispatchCso()));
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().BoundCso()));
EXPECT_EQ(Emitter().DrawProgramSetCount(), 2u);
}
TEST(ProgramEmit, AnUnchangedProgramEmitsNothingAtAll) {
EmitterScope scope;
const GLuint name = MakeVsFsProgram();
GL::UseProgram(name);
ASSERT_GT(Emitter().EmitShaderState(Ctx()), 0u);
// The version-first skip: nothing hashed, nothing copied, nothing emitted, and the
// return value is the bytes that went on the wire - zero.
EXPECT_EQ(Emitter().EmitShaderState(Ctx()), 0u);
EXPECT_EQ(Emitter().CreateCount(), 1u);
EXPECT_EQ(Emitter().BindCount(), 1u);
EXPECT_EQ(Emitter().DrawProgramSetCount(), 1u);
}
// set_global_constants is suppressed against a (ShaderCso, Version) key, and A RE-ISSUED
// create_shader_state CLEARS THE APPLIER's DEFAULT UNIFORM BLOCK (wire's W6). So the key
// has to die with the re-issue, or the block is never re-sent and the server draws the
// program with a zeroed one while the client's scratch still holds the live values.
//
// WHICH HALF OF THIS CASE IS THE DISCRIMINATOR, said plainly. The second half - "the block
// goes out again after a relink" - is also true without the fix, because
// BumpLinkObservableVersions bumps the UBO content version alongside the link version, so
// the Version half of the key moves on its own for the one re-issue trigger that exists
// today. The FIRST half is the one that goes red when the invalidation is deleted, and it
// is the property the contract actually needs: after a re-issue the emitter must hold NO
// key at all, so no future re-issue trigger - a recycled slot, a re-issue driven by
// anything that does not happen to move the content version - can leave the applier's
// cleared block latched as "already sent".
TEST(ProgramEmit, AReIssuedCreateReSendsTheDefaultUniformBlock) {
EmitterScope scope;
const GLuint name = MakeVsFsProgram();
GL::UseProgram(name);
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(name);
ASSERT_TRUE(program);
ASSERT_GT(Emitter().EmitShaderState(Ctx()), 0u);
ASSERT_EQ(Emitter().CreateCount(), 1u);
// Driven to the fixed point rather than assumed to settle in one call: the first
// EmitGlobalConstants reads GetUBOContentVersion() BEFORE GetUBOSize() joins phase B,
// and that join bumps the counter, so the first key is one behind by construction.
for (int i = 0; i < 8 && Emitter().EmitGlobalConstants(Ctx()) > 0u; ++i) {
}
const Uint64 setsBefore = Emitter().GlobalConstantsSetCount();
ASSERT_GT(setsBefore, 0u) << "this program declares a uniform, so a block must have gone out";
const MGPipeHandle cso = Emitter().GlobalConstantsCso();
ASSERT_FALSE(MGPipeHandleIsNull(cso));
EXPECT_EQ(cso, Emitter().DrawCso());
// The latch really is latched.
EXPECT_EQ(Emitter().EmitGlobalConstants(Ctx()), 0u);
EXPECT_EQ(Emitter().GlobalConstantsSetCount(), setsBefore);
// A RELINK, which is the one thing that makes AcquireShaderCso re-issue on the same
// handle. A failed relink is the case the design worries about - GL keeps a program
// that is active for a stage running on its previous executable - and this frontend
// additionally clears the phase-B scratch in Link()'s prologue, so the populated-block
// half of that scenario is not reachable here; the re-issue is, and it is what the
// applier reacts to.
const Uint32 linkVersionBefore = program->GetLinkVersion();
GL::LinkProgram(name);
ASSERT_NE(program->GetLinkVersion(), linkVersionBefore) << "the relink really has to move it";
Emitter().EmitShaderState(Ctx());
ASSERT_EQ(Emitter().CreateCount(), 2u) << "the create really has to be re-issued";
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().GlobalConstantsCso()))
<< "the applier cleared the record's default uniform block on the re-issue, so the "
"key that suppresses set_global_constants must not survive it";
EXPECT_EQ(Emitter().GlobalConstantsVersion(), kMGPipeGlobalConstantsNeverUploaded);
// And the block really is sent again.
for (int i = 0; i < 8 && Emitter().EmitGlobalConstants(Ctx()) > 0u; ++i) {
}
EXPECT_GT(Emitter().GlobalConstantsSetCount(), setsBefore);
}
// FINAL REVIEW C-2: the death helper forwards to this emitter before the slot is freed, so
// a dead program's handle no longer reads as published in the record memo between the death
// and the recycle (the memo's own Gen test covers only the recycle).
TEST(ProgramEmit, ADeadProgramsRecordLatchIsRetiredAtItsDeath) {
EmitterScope scope;
const GLuint name = MakeVsFsProgram();
MGPipeHandle handle{};
{
const SharedPtr<ProgramObject>& program = Ctx().GetProgramObject(name);
ASSERT_TRUE(program);
Uint64 bytes = 0;
handle = Emitter().AcquireShaderCso(*program, bytes);
ASSERT_FALSE(MGPipeHandleIsNull(handle));
ASSERT_TRUE(Emitter().RecordIsPublished(handle));
}
GL::DeleteProgram(name); // not in use: the frontend object dies here
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::ShaderCso, handle));
EXPECT_FALSE(Emitter().RecordIsPublished(handle))
<< "a dead program still reads as published in the program emitter's memo";
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
fs::temp_directory_path() / ("mobilegl-programemit-test-" + std::to_string(ProcessId()) + ".log");
std::error_code ec;
fs::remove(path, ec);
g_logPath = path.string();
#if defined(_WIN32)
_putenv_s("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str());
#else
setenv("MOBILEGL_LOG_FILE_PATH", g_logPath.c_str(), 1);
#endif
#if MOBILEGL_PIPE_PUSH
MobileGL::Initialize();
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
fs::remove(path, ec);
return rc;
}
+389 -1
View File
@@ -56,6 +56,13 @@
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <Config.h>
// MOBILEGL_PIPE_POISON is DERIVED in the header below (PipeInputs.h:20-26) and nowhere
// else, so a TU that tests it without this include silently reads it as 0. That is
// invisible in a push build (where it really is 0) and in a verify build (where
// -DMOBILEGL_PIPE_VERIFY=1 is on the command line); MOBILEGL_BUILD_DISAGGREGATED is the
// one arming condition that lives behind the header, so a split build is the first place
// the refusals below stop being fatal while the expectations still say they are.
#include <MG_Backend/MGPipe/PipeInputs.h>
#include <MG_Impl/Pipe/ResourceTracker.h>
#include <MG_Impl/Pipe/SlotAllocator.h>
#include <MG_Impl/Pipe/VertexInputEmit.h>
@@ -156,6 +163,27 @@ namespace {
}
};
// "A BACKEND IS PRESENT", which since ID-39 is a thing the applier ASKS: every P4a-family
// entry point declines a record - and counts RefusedNoConsumer - when no backend has
// registered MGPipeResourceOps, because acceptance is a contract with the emitter and an
// accepted record nothing will read makes the client clear a dirty flag the legacy pull
// path still owed. A case that wants the P4a half of this applier to behave as it does
// under DirectGLES scopes this on; the case that wants the OTHER arm simply does not.
//
// THE TABLE IS EMPTY AND THAT IS DELIBERATE. Its hooks are the BUFFER family's, and every
// non-buffer resource row is stored and returned rather than dispatched (see
// MGPipeApplyResourceCreate) - so what registering it changes here is the consumer question
// and nothing else. It nests: the previous table is restored, not nulled.
struct ScopedResourceOps {
ScopedResourceOps() : m_saved(MGPipeGetResourceOps()) {
static const MGPipeResourceOps kEmpty{};
MGPipeSetResourceOps(&kEmpty);
}
~ScopedResourceOps() { MGPipeSetResourceOps(m_saved); }
const MGPipeResourceOps* m_saved;
};
MGPResourceDesc BufferDesc(MGPipeHandle res, Uint32 width, Uint32 glName) {
MGPResourceDesc desc{};
desc.Resource = res;
@@ -1225,7 +1253,12 @@ namespace {
TEST(ResourceEmit, TheLiveHostWritesWireFiresOnTheCallAPersistentMapProducerWouldSetItOn) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#elif !(MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY)
// MOBILEGL_PIPE_VERIFY alone, NOT `POISON || VERIFY`. PinNoLiveHostWrites is compiled
// under `#if MOBILEGL_PIPE_VERIFY` only (PipeApply.cpp:838-853), so in a split build -
// where POISON is armed by MOBILEGL_BUILD_DISAGGREGATED but VERIFY is off - the wire
// genuinely is compiled out and this case must skip. The wrong disjunction was masked
// until now by POISON being invisible in this TU at all (see the include at the top).
#elif !MOBILEGL_PIPE_VERIFY
GTEST_SKIP() << "Fatal{PipeLiveHostWrites} is a MOBILEGL_PIPE_VERIFY wire and is compiled out here";
#elif !MGTEST_HAVE_FORK
GTEST_SKIP() << "no fork on this platform; the wire's verdict is std::abort()";
@@ -1323,6 +1356,361 @@ namespace {
#endif
}
// =====================================================================================
// P4a: the four resource entry points now BRANCH ON THE DESCRIPTOR'S TARGET.
//
// The slot spaces of kinds Buffer, Texture and Renderbuffer are independent - the client
// allocator is per kind - so one slot-indexed table would alias three live objects onto one
// record. These cases are about the branch and nothing else: which table a call lands in,
// that the three do not see each other, and that a target or a kind the catalogue does not
// name is refused rather than routed to whichever table came first. The texture family's
// own behaviour (parameters, the sub-data validator, the pending-upload set) is in
// TextureEmitTest beside the emitter cases it belongs with.
// =====================================================================================
#if MOBILEGL_PIPE_PUSH
MGPResourceDesc TargetedDesc(MGPipeHandle res, MGPipeResourceTarget target, Uint32 width, Uint32 glName) {
MGPResourceDesc desc = BufferDesc(res, width, glName);
desc.Target = static_cast<Uint8>(target);
return desc;
}
MGPHandleOnly KindHandle(MGPipeHandle res, MGPipeKind kind) {
return MGPHandleOnly{res, static_cast<Uint32>(kind), 0};
}
#endif
// ONE SLOT NUMBER, THREE LIVE OBJECTS, THREE RECORDS. This is the case that fails the
// instant the applier goes back to one table: every assertion below is about slot 7 being
// three different things at once, which is exactly what the client allocator hands out.
TEST(ResourceEmit, TheThreeResourceKindsKeepTheirOwnSlotSpaceAndDoNotSeeEachOther) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
// The texture and renderbuffer rows below are P4a's, and P4a's belt declines those on a
// backend that consumes none of them (ID-39) - so this case says which arm it is about.
ScopedResourceOps consumer;
const MGPipeHandle shared{7, 3};
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Buffer, 0, 11));
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Tex2D, 0, 22));
MGPipeApplyResourceCreate(TargetedDesc(shared, MGPipeResourceTarget::Renderbuffer, 0, 33));
ASSERT_GT(MGPipeApplier().Resources.size(), 7u);
ASSERT_GT(MGPipeApplier().TextureResources.size(), 7u);
ASSERT_GT(MGPipeApplier().RenderbufferResources.size(), 7u);
EXPECT_EQ(MGPipeApplier().Resources[7].Desc.GlNameForDiag, 11u);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Desc.GlNameForDiag, 22u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Desc.GlNameForDiag, 33u);
// A respecify of one of them moves ONE record's serial and one record's extent.
MGPipeApplyResourceRespecify(TargetedDesc(shared, MGPipeResourceTarget::Tex2D, 256, 22), nullptr);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Desc.Width, 256u);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Serial, 1u);
EXPECT_EQ(MGPipeApplier().Resources[7].Desc.Width, 0u) << "a texture respecify moved the buffer";
EXPECT_EQ(MGPipeApplier().Resources[7].Serial, 0u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Serial, 0u);
// A renderbuffer restorage is the publication D-D2 asks for: the frontend raises no
// version for it, so the emission IS the notice, and the applier holds the new extent.
MGPipeApplyResourceRespecify(TargetedDesc(shared, MGPipeResourceTarget::Renderbuffer, 1024, 33),
nullptr);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Desc.Width, 1024u);
EXPECT_EQ(MGPipeApplier().RenderbufferResources[7].Serial, 1u);
// And a destroy takes the record its KIND names, and only that one.
MGPipeApplyResourceDestroy(KindHandle(shared, MGPipeKind::Texture));
EXPECT_FALSE(MGPipeApplier().TextureResources[7].Live);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Gen, 3u) << "a destroy keeps the generation";
EXPECT_TRUE(MGPipeApplier().Resources[7].Live) << "a texture destroy dropped the buffer's record";
EXPECT_TRUE(MGPipeApplier().RenderbufferResources[7].Live);
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u);
#endif
}
// ID-39: THE APPLIER'S HALF OF THE "NO CONSUMER" RULE, over every P4a-family entry point.
//
// WHY AN APPLIER ASKS A QUESTION ABOUT THE BACKEND AT ALL is written beside
// MGPipeApplierState::RefusedNoConsumer: acceptance became a CONTRACT WITH THE CLIENT at
// ID-18 M3 - the emitters clear a texture level's dirty flags, advance their descriptor
// mirrors and latch their suppressors on the answer these calls return - so an applier that
// accepts a record nothing in the process will ever read makes the client forget work the
// legacy pull path still owed. On DirectVulkan, which registers no MGPipeResourceOps and
// has none of P4a's twins, that put 66 texture-upload-shaped integration-gpu cases red on
// the push build while the pull build stayed 966/966 green.
//
// IT IS A BELT AND NOT THE GATE. The client's gate is FamilyIsLive in
// MG_Impl/Pipe/PipeFill.cpp (pinned by TextureEmit.WithNoBackendConsumerTheFamilyGateIsFalse
// AndNothingReachesTheApplier) and it stops the emission upstream. This is under it, and it
// is not redundant: GL_Framebuffer.cpp's PipePublishFramebufferByName reaches the
// framebuffer emitter DIRECTLY at the fifteen DSA sites, without passing through PipeFill,
// so set_framebuffer_state is a record that can arrive here on a backend with no consumer.
//
// THE DEATH PATHS ARE DELIBERATELY NOT IN THE LIST and the second half of the case says so:
// a destroy is idempotent cleanup that must keep working whatever the registration did.
TEST(ResourceEmit, EveryP4aFamilyEntryPointDeclinesWhenNoBackendRegisteredTheConsumer) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard; // leaves the table UNREGISTERED, which is this half's whole point
ASSERT_EQ(MGPipeGetResourceOps(), nullptr);
const MGPipeHandle texture{7, 3};
const MGPipeHandle cso{9, 1};
const MGPipeHandle fbo{4, 2};
MGPTextureParams params{};
params.Res = texture;
params.BuiltinSampler = cso;
MGPSubData upload{};
upload.Res = texture;
upload.Target = MGPipePackSubDataTarget(static_cast<Uint32>(MGPipeResourceTarget::Tex2D), 0u);
MGPFramebufferState fboState{};
fboState.Fbo = fbo;
fboState.Target = static_cast<Uint8>(MGPipeFramebufferTarget::Draw);
MGPSamplerDesc samplerDesc{};
samplerDesc.Cso = cso;
const SamplerParameters samplerParams{};
MGPSamplerView view{};
view.Cso = cso;
view.Texture = texture;
const MGPSamplerViews viewSet{0, 1, 0};
const MGPBoundView viewTail[1]{};
const MGPSamplerStates stateSet{0, 1, 0};
const MGPipeHandle stateTail[1]{kMGPipeNullHandle};
const MGPShaderImages imageSet{0, 1, 0};
const MGPImageView imageTail[1]{};
MGPProgramDesc program{};
program.Cso = cso;
program.StageMask = 0x3u;
const MG_State::GLState::LinkArtifacts link;
const MG_State::GLState::SpirvArtifacts spirv;
MGPGlobalConstants constants{};
constants.ShaderCso = cso;
const MGPHandleOnly csoHandle{cso, static_cast<Uint32>(MGPipeKind::ShaderCso), 0};
// ---- with no consumer: every one of them declines, and NOTHING is stored ----
EXPECT_FALSE(MGPipeApplyResourceCreate(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 0, 22)));
EXPECT_FALSE(
MGPipeApplyResourceRespecify(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 64, 22), nullptr));
EXPECT_FALSE(MGPipeApplyResourceSubData(upload, nullptr));
MGPipeApplySetTextureParams(params);
MGPipeApplySetFramebufferState(fboState);
MGPipeApplyCreateSamplerState(samplerDesc, &samplerParams);
MGPipeApplyCreateSamplerView(view);
MGPipeApplySetSamplerViews(viewSet, viewTail);
MGPipeApplyBindSamplerStates(stateSet, stateTail);
MGPipeApplySetShaderImages(imageSet, imageTail);
MGPipeApplyCreateShaderState(program, &link, &spirv);
MGPipeApplyBindShaderState(csoHandle);
MGPipeApplySetDrawProgram(csoHandle);
MGPipeApplySetDispatchProgram(csoHandle);
MGPipeApplySetGlobalConstants(constants, nullptr);
// FIFTEEN CALLS, FIFTEEN REFUSALS, AND THE NUMBER IS THE ASSERTION: an entry point that
// is added to a P4a family later and forgets the belt makes this line fail rather than
// silently accepting a record on a backend that reads none.
EXPECT_EQ(MGPipeApplier().RefusedNoConsumer, 15u);
// AND NOT ONE OF THE OTHER THREE MOVED. The refusal is a configuration fact, not a seam
// defect, so it must not read as one to an operator grepping the counters.
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u);
EXPECT_EQ(MGPipeApplier().RefusedObjectCalls, 0u);
EXPECT_EQ(MGPipeApplier().StaleFramebufferRecordLookups, 0u);
// NOTHING IS LIVE, which is the property, rather than "no table exists". The belt stands
// AFTER each entry point's own record-shape checks so that a malformed record is
// Fatal{ProtocolCorruption} on every backend and not only on the ones that consume - and
// the bound check for the four create-shaped calls IS RecordAt, which grows the table to
// the slot on its way to answering. So a refused create may leave a zeroed row behind
// and stores nothing in it. It costs nothing where it matters: on a backend with no
// consumer the client's gate emits none of these at all, and the one record that reaches
// this applier without passing that gate - set_framebuffer_state, published by name from
// GL_Framebuffer.cpp - is declined in front of its RecordAt.
const auto nothingLiveAt = [](const auto& table, SizeT slot) {
return table.size() <= slot || !table[slot].Live;
};
EXPECT_TRUE(nothingLiveAt(MGPipeApplier().TextureResources, 7));
EXPECT_TRUE(nothingLiveAt(MGPipeApplier().SamplerCsos, 9));
EXPECT_TRUE(nothingLiveAt(MGPipeApplier().SamplerViewCsos, 9));
EXPECT_TRUE(nothingLiveAt(MGPipeApplier().ShaderCsos, 9));
// set_framebuffer_state's table is the one that must not even be grown: it is declined
// in front of its RecordAt, because it is the one call a backend with no consumer can
// actually receive.
EXPECT_TRUE(MGPipeApplier().FramebufferRecords.empty());
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 0u);
EXPECT_EQ(MGPipeApplier().SamplerStateCount, 0u);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 0u);
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().BoundShaderCso));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DrawProgram));
EXPECT_TRUE(MGPipeHandleIsNull(MGPipeApplier().DispatchProgram));
EXPECT_TRUE(MGPipeHandleIsNull(
MGPipeApplier().BoundFramebuffer[static_cast<SizeT>(MGPipeFramebufferTarget::Draw)]));
// ---- and with one, every one of them lands. Same records, same order ----
{
ScopedResourceOps consumer;
const Uint64 refusalsBefore = MGPipeApplier().RefusedNoConsumer;
EXPECT_TRUE(
MGPipeApplyResourceCreate(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 0, 22)));
EXPECT_TRUE(MGPipeApplyResourceRespecify(
TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 64, 22), nullptr));
MGPipeApplySetTextureParams(params);
MGPipeApplySetFramebufferState(fboState);
MGPipeApplyCreateSamplerState(samplerDesc, &samplerParams);
MGPipeApplyCreateSamplerView(view);
MGPipeApplySetSamplerViews(viewSet, viewTail);
MGPipeApplyBindSamplerStates(stateSet, stateTail);
MGPipeApplySetShaderImages(imageSet, imageTail);
MGPipeApplyCreateShaderState(program, &link, &spirv);
MGPipeApplyBindShaderState(csoHandle);
MGPipeApplySetDrawProgram(csoHandle);
MGPipeApplySetDispatchProgram(csoHandle);
MGPipeApplySetGlobalConstants(constants, nullptr);
EXPECT_EQ(MGPipeApplier().RefusedNoConsumer, refusalsBefore);
ASSERT_GT(MGPipeApplier().TextureResources.size(), 7u);
EXPECT_TRUE(MGPipeApplier().TextureResources[7].Live);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Desc.Width, 64u);
EXPECT_EQ(MGPipeApplier().TextureResources[7].Params.BuiltinSampler, cso);
ASSERT_GT(MGPipeApplier().FramebufferRecords.size(), 4u);
EXPECT_TRUE(MGPipeApplier().FramebufferRecords[4].Live);
EXPECT_EQ(MGPipeApplier().BoundFramebuffer[static_cast<SizeT>(MGPipeFramebufferTarget::Draw)],
fbo);
ASSERT_GT(MGPipeApplier().SamplerCsos.size(), 9u);
EXPECT_TRUE(MGPipeApplier().SamplerCsos[9].Live);
ASSERT_GT(MGPipeApplier().SamplerViewCsos.size(), 9u);
EXPECT_TRUE(MGPipeApplier().SamplerViewCsos[9].Live);
ASSERT_GT(MGPipeApplier().ShaderCsos.size(), 9u);
EXPECT_TRUE(MGPipeApplier().ShaderCsos[9].Live);
EXPECT_EQ(MGPipeApplier().SamplerViewCount, 1u);
EXPECT_EQ(MGPipeApplier().SamplerStateCount, 1u);
EXPECT_EQ(MGPipeApplier().ShaderImageCount, 1u);
EXPECT_EQ(MGPipeApplier().BoundShaderCso, cso);
EXPECT_EQ(MGPipeApplier().DrawProgram, cso);
EXPECT_EQ(MGPipeApplier().DispatchProgram, cso);
// resource_subdata's ACCEPTED path wants a real destination box against real
// storage, which is a texture-emitter fixture and not this file's; it is proved end
// to end by TextureEmit.WithNoBackendConsumerTheFamilyGateIsFalseAndNothingReaches
// TheApplier's second half (SubDataCount 1, RefusedSubDataCount 0). Driving a
// half-built record through it here would trip the upload validator's own wire,
// which is a different rule and not this case's.
// AND THE DEATH PATHS ARE NOT BELTED, which is the other half of the ruling: they
// are idempotent cleanup and they run on whatever the registration is. Driven with
// the table registered here and asserted UNCOUNTED, so that a later commit which
// adds them to the belt has to change this line.
MGPipeApplyDeleteShaderState(csoHandle);
MGPipeApplyResourceDestroy(KindHandle(texture, MGPipeKind::Texture));
EXPECT_EQ(MGPipeApplier().RefusedNoConsumer, refusalsBefore);
EXPECT_FALSE(MGPipeApplier().ShaderCsos[9].Live);
EXPECT_FALSE(MGPipeApplier().TextureResources[7].Live);
}
#endif
}
// Neither branch may fall through to a table it was not named. A target or a kind outside
// the catalogue would otherwise land in whichever table the code happened to reach first,
// and destroy a live object of a kind the call was never about.
TEST(ResourceEmit, AResourceTargetOrKindTheCatalogueDoesNotNameIsRefusedRatherThanRouted) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
const MGPipeHandle res{5, 1};
MGPResourceDesc unnamed = BufferDesc(res, 0, 44);
unnamed.Target = static_cast<Uint8>(MGPipeResourceTarget::Count);
ExpectRefusedNaming("resource_create {slot=5, gen=1, glName=44}: the descriptor names no resource "
"target",
[&unnamed]() { MGPipeApplyResourceCreate(unnamed); });
EXPECT_TRUE(MGPipeApplier().Resources.empty());
EXPECT_TRUE(MGPipeApplier().TextureResources.empty());
EXPECT_TRUE(MGPipeApplier().RenderbufferResources.empty());
ExpectRefusedNaming("resource_respecify {slot=5, gen=1, glName=44}: the descriptor names no "
"resource target",
[&unnamed]() { MGPipeApplyResourceRespecify(unnamed, nullptr); });
const MGPHandleOnly wrongKind = KindHandle(res, MGPipeKind::SamplerCso);
ExpectRefusedNaming("resource_destroy {slot=5, gen=1}: the handle names no resource kind",
[&wrongKind]() { MGPipeApplyResourceDestroy(wrongKind); });
// AND unmap_persistent GIVES THE SAME VERDICT, because it is the only one of the four
// buffer-only calls that carries a discriminator at all. An assertion here is not a
// check: MOBILEGL_ASSERT compiles out at INFO, which is what all three gate builds and
// every shipped build are, so a texture-kinded record used to walk into ResolveResource
// and alias whatever BUFFER holds that slot - which is exactly what the destroy's Fatal
// above exists to stop. The live buffer at slot 5 is what makes the aliasing reachable.
MGPipeApplyResourceCreate(BufferDesc(res, 0, 44));
MGPipeApplyResourceRespecify(BufferDesc(res, 256, 44), nullptr);
ASSERT_TRUE(MGPipeApplier().Resources[5].Live);
const MGPHandleOnly textureKind = KindHandle(res, MGPipeKind::Texture);
ExpectRefusedNaming("unmap_persistent {slot=5, gen=1}: the persistent donation is the buffer "
"family's and the handle names another kind",
[&textureKind]() { MGPipeApplyUnmapPersistent(textureKind); });
EXPECT_EQ(MGPipeApplier().RefusedResourceCalls, 0u)
<< "a corrupt record is not a dropped call and must not be counted as one";
#endif
}
// A texture's resource calls reach NO backend function pointer, and that is the structural
// decision the phase rests on rather than an omission: nothing in the texture family
// dispatches at GL-call time today, so the record IS the publication. A spy table that saw
// one of them would mean P4a had grown an op-table path nobody designed.
TEST(ResourceEmit, NoTextureOrRenderbufferResourceCallReachesTheBackendOpTable) {
#if !MOBILEGL_PIPE_PUSH
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no applier in this build";
#else
ApplierGuard guard;
g_spy = SpyState{};
MGPipeSetResourceOps(&kSpyOps);
const MGPipeHandle texture{3, 1};
const MGPipeHandle renderbuffer{4, 1};
const Uint8 texels[64] = {};
MGPipeApplyResourceCreate(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 0, 55));
MGPipeApplyResourceRespecify(TargetedDesc(texture, MGPipeResourceTarget::Tex2D, 8, 55), nullptr);
MGPipeApplyResourceCreate(TargetedDesc(renderbuffer, MGPipeResourceTarget::Renderbuffer, 0, 66));
MGPipeApplyResourceRespecify(TargetedDesc(renderbuffer, MGPipeResourceTarget::Renderbuffer, 8, 66),
nullptr);
MGPSubData upload{};
upload.Res = texture;
upload.Target = static_cast<Uint16>(MGPipeResourceTarget::Tex2D);
upload.UnionBox = MGPBox{0, 0, 0, 4, 4, 1};
MGPipeApplyResourceSubData(upload, texels);
MGPipeApplyResourceDestroy(KindHandle(texture, MGPipeKind::Texture));
MGPipeApplyResourceDestroy(KindHandle(renderbuffer, MGPipeKind::Renderbuffer));
EXPECT_EQ(g_spy.Creates, 0u);
EXPECT_EQ(g_spy.Respecifies, 0u);
EXPECT_EQ(g_spy.SubDatas, 0u);
EXPECT_EQ(g_spy.Destroys, 0u);
// The same five calls on a BUFFER still dispatch, which is what proves the count above
// is the branch working rather than the table being uninstalled.
const MGPipeHandle buffer{3, 1};
MGPipeApplyResourceCreate(BufferDesc(buffer, 0, 77));
MGPipeApplyResourceRespecify(BufferDesc(buffer, 64, 77), nullptr);
MGPipeApplyResourceSubData(BufferWrite(buffer, 0, 16), texels);
MGPipeApplyResourceDestroy(BufferHandle(buffer));
EXPECT_EQ(g_spy.Creates, 1u);
EXPECT_EQ(g_spy.Respecifies, 1u);
EXPECT_EQ(g_spy.SubDatas, 1u);
EXPECT_EQ(g_spy.Destroys, 1u);
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 REQUIRES THE PULL AND PUSH ctest NAME SETS TO BE IDENTICAL, name for name, so a
// push-only case cannot be ABSENT from a pull build - it has to be there and SKIP. This
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+271 -4
View File
@@ -76,6 +76,15 @@ namespace {
X(TrackerWalk, TheIndexBufferBitDoesNotFireOnAnUnrelatedBufferWrite) \
X(TrackerWalk, TheIndexBufferBitFiresWhenTheSlotVersionWrapsOntoADifferentBuffer) \
X(TrackerWalk, ABaseInstanceSurvivesTheFirstWalkOnAFreshContext) \
X(TrackerWalk, ASamplerBindAloneFiresTheSamplerStateBit) \
X(TrackerWalk, ARestagedProgramPipelineFiresTheProgramBits) \
X(TrackerWalk, ARelinkOfAStageProgramFiresTheProgramBits) \
X(TrackerWalk, UseProgramZeroLeavesTheBoundPipelineDrivingTheProgramBits) \
X(TrackerAggregates, ATextureStorageDefinitionMovesTheFramebufferAggregateToo) \
X(TrackerAggregates, ARenderbufferStorageDefinitionMovesTheFramebufferAggregate) \
X(TrackerWalk, AProgramSwitchAloneFiresTheSamplerViewBit) \
X(TrackerWalk, ATextureParameterAloneFiresTheSamplerViewBit) \
X(TrackerWalk, AProgramSwitchBetweenEqualImageUnitCountersFiresTheShaderImageBit) \
X(TrackerAttribPayload, AFloatWriteCarriesTheFloatBitsAndNamesItsClass) \
X(TrackerAttribPayload, AnIntWriteCarriesTheIntWordsAndNamesItsClass) \
X(TrackerAttribPayload, AUintWriteCarriesTheUintWordsAndNamesItsClass) \
@@ -228,6 +237,39 @@ namespace {
SUCCEED();
}
// P4a FABLE SEAM F-3. set_framebuffer_state INLINES an attachment's format, extent and
// samples (D-C1), so the setters that define a texture's storage are setters of a
// framebuffer-record field - and the record-field -> setter -> shutter rule (Tracker.h's
// table) says they must move the aggregate bit 11 reads. They still move the params
// aggregate they always moved; what this case pins is the SECOND bump, which the
// "moves only" cases above cannot see and which is the whole of F-3's fix.
TEST_F(TrackerAggregates, ATextureStorageDefinitionMovesTheFramebufferAggregateToo) {
const auto& tex = Ctx().CreateTextureObject(3, TextureTarget::Texture2D);
ASSERT_TRUE(tex != nullptr);
const Snapshot before = Snap();
tex->SetInternalFormat(MobileGL::TextureInternalFormat::RGBA8);
const Snapshot after = Snap();
EXPECT_GT(after[MGPipeAggregate::FramebufferAttachment], before[MGPipeAggregate::FramebufferAttachment])
<< "a texture whose storage is redefined WHILE ATTACHED left set_framebuffer_state "
"describing the previous format (F-3)";
EXPECT_GT(after[MGPipeAggregate::TextureParams], before[MGPipeAggregate::TextureParams]);
EXPECT_EQ(after[MGPipeAggregate::TextureContent], before[MGPipeAggregate::TextureContent]);
EXPECT_EQ(after[MGPipeAggregate::VaoAttribute], before[MGPipeAggregate::VaoAttribute]);
EXPECT_EQ(after[MGPipeAggregate::BufferChange], before[MGPipeAggregate::BufferChange]);
EXPECT_EQ(after[MGPipeAggregate::VertexAttribDefault], before[MGPipeAggregate::VertexAttribDefault]);
}
// The renderbuffer twin, and the one that had NO aggregate at all before: its three storage
// setters bumped no version and raised no notice (D-D2 closed the resource record by emitting
// from the entry point and left the framebuffer record stale).
TEST_F(TrackerAggregates, ARenderbufferStorageDefinitionMovesTheFramebufferAggregate) {
const auto& rbo = Ctx().CreateRenderbufferObject(1);
ASSERT_TRUE(rbo != nullptr);
const Snapshot before = Snap();
rbo->AllocateStorage(IntVec2{8, 8});
ExpectOnly(MGPipeAggregate::FramebufferAttachment, before, Snap());
}
// ===================================================================================
// The dirty walk itself, and the render-state emission it drives (P2 brief D4, D6, D7)
// ===================================================================================
@@ -320,15 +362,29 @@ namespace {
// keeps the name it was born with and follows the phase constant instead of a literal
// five: what it has always asserted is "a bit names a subsystem if and only if this build
// emits a call for it", which is the property the emission gate and the residual-fill
// skip both rest on. P3a took the vertex-input family over, so the set it compares
// against is now kMGPipeDirtyEmittedAtP3a - and a bit that gained an arm without gaining
// an emitter, or the reverse, still fails here.
// skip both rest on. P3a took the vertex-input family over and P4a takes seven more bits
// across four subsystems, so the set it compares against is now kMGPipeDirtyEmittedAtP4a -
// and a bit that gained an arm without gaining an emitter, or the reverse, still fails
// here.
TEST_F(TrackerWalk, OnlyTheFiveEmittedBitsNameASubsystem) {
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
const auto bit = static_cast<MGPipeDirty>(i);
const Bool emitted = (kMGPipeDirtyEmittedAtP3a & MGPipeDirtyBit(bit)) != 0;
const Bool emitted = (kMGPipeDirtyEmittedAtP4a & MGPipeDirtyBit(bit)) != 0;
EXPECT_EQ(MGPipeSubsystemForDirty(bit) != 0, emitted) << kMGPipeDirtyNames[i];
}
// Each phase's constant SURVIVES as the next phase's A/B control, so the three are
// pinned as a chain rather than one being edited into the next: 0x1ff is P4a's "T2"
// arm and 0x7f is P3a's, and an operator's recorded mask has to keep meaning what it
// meant.
EXPECT_EQ(kMGPipeDirtyEmittedAtP4a & kMGPipeDirtyEmittedAtP3a, kMGPipeDirtyEmittedAtP3a);
EXPECT_EQ(kMGPipeDirtyEmittedAtP3a & kMGPipeDirtyEmittedAtP2, kMGPipeDirtyEmittedAtP2);
// The three bits P4a still does not emit for - the const-buffer, shader-buffer and
// stream-output sets - name no subsystem, so their fields keep going through the
// residual fill. Stated positively as well as through the loop above, because "only
// these three are left" is the phase's own scope statement.
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewConstBuffers), 0u);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBuffers), 0u);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewSoTargets), 0u);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), kMGPipeSubsystemRenderState);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack), kMGPipeSubsystemPixelPack);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), kMGPipeSubsystemPatchState);
@@ -339,6 +395,26 @@ namespace {
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexElements), kMGPipeSubsystemVertexInput);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexBuffers), kMGPipeSubsystemVertexInput);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewIndexBuffer), kMGPipeSubsystemVertexInput);
// P4a's seven, across FOUR subsystems, and the grouping is the whole point: the three
// program bits are one family because an operator switching programs off has to get
// the whole legacy arm, and so are the three unit-set bits.
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShader), kMGPipeSubsystemPrograms);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBindings), kMGPipeSubsystemPrograms);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewGlobalConstants), kMGPipeSubsystemPrograms);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewFramebuffer), kMGPipeSubsystemFramebuffer);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplerViews), kMGPipeSubsystemSamplers);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplers), kMGPipeSubsystemSamplers);
EXPECT_EQ(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderImages), kMGPipeSubsystemSamplers);
// AND NO BIT NAMES THE TEXTURE-RESOURCE SUBSYSTEM. Its calls 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, so a bit
// that started naming it would gate the emission twice and the two gates would
// disagree the first time one of them was edited.
for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) {
EXPECT_NE(MGPipeSubsystemForDirty(static_cast<MGPipeDirty>(i)),
kMGPipeSubsystemTextureResources)
<< kMGPipeDirtyNames[i];
}
}
TEST_F(TrackerWalk, TheFirstWalkOnAFreshContextPublishesEverything) {
@@ -584,6 +660,197 @@ namespace {
m_cache.Reset();
}
// ===================================================================================
// P4a c0d: the two under-fires that only a bit WITH an emitter can be hurt by
// ===================================================================================
// BIT 13 IS WHAT bind_sampler_states IS EMITTED OFF (PipeFill.cpp gates EmitSamplerStates
// on NEW_SAMPLERS), and glBindSampler moved neither half of what its shutter used to read:
// GL_Sampler.cpp's BindSampler_State calls NoteTextureUnitTouched and then
// TextureUnit::SetSamplerObject, and BOTH of those bump the TEXTURE BIND generation, which
// is bit 12's. The only writers of the sampling-resolution generation are parameter
// changes. This case makes those two calls, in that order, with nothing else moving.
TEST_F(TrackerWalk, ASamplerBindAloneFiresTheSamplerStateBit) {
ASSERT_TRUE(Ctx().CreateSamplerObject(1) != nullptr);
ASSERT_TRUE(Ctx().CreateSamplerObject(2) != nullptr);
Ctx().NoteTextureUnitTouched(3);
Ctx().GetTextureUnitObject(3).SetSamplerObject(Ctx().GetSamplerObject(1));
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
// The one and only change: unit 3 now carries a DIFFERENT sampler object, whose
// parameters happen to differ from the first one's. No glSamplerParameter*, no
// glTexParameter*, so the sampling-resolution generation cannot have moved.
Ctx().NoteTextureUnitTouched(3);
Ctx().GetTextureUnitObject(3).SetSamplerObject(Ctx().GetSamplerObject(2));
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewSamplers), 0u)
<< "bind_sampler_states is emitted off NEW_SAMPLERS and never saw the sampler bind";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u)
<< "the view set is re-resolved on a sampler bind too - completeness depends on the "
"effective sampler - and that half was already right";
EXPECT_EQ(Walk(), 0u) << "the widened shutter fires forever";
}
// BITS 6/7/8 UNDER A SEPARABLE PROGRAM PIPELINE. GetCurrentProgram() is null for the whole
// life of a bound pipeline, so all three shutters used to latch 0 and never move again
// after the first walk: glUseProgramStages would rebuild the composite and EmitShaderState
// would never be called, leaving the new composite with no ShaderCso handle at all.
TEST_F(TrackerWalk, ARestagedProgramPipelineFiresTheProgramBits) {
Vector<Uint> names;
Ctx().GenProgramPipelineNames(1, names);
ASSERT_EQ(names.size(), 1u);
Ctx().CreateProgramPipelineObject(names[0]);
Ctx().BindProgramPipelineObject(names[0]);
const auto& pipeline = Ctx().GetBoundProgramPipeline();
ASSERT_TRUE(pipeline != nullptr);
ASSERT_TRUE(Ctx().GetCurrentProgram() == nullptr)
<< "the premise of this case is that the program family has no current program";
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
// What glUseProgramStages does at the end of its validation: one stage changes.
const Uint vertex = Ctx().CreateProgram();
pipeline->SetStageProgram(MobileGL::ShaderStage::Vertex, Ctx().GetProgramObject(vertex));
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u)
<< "the re-composited pipeline would never get a ShaderCso handle";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShaderBindings), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants), 0u)
<< "set_global_constants would never be sent for a pipeline draw";
EXPECT_EQ(Walk(), 0u) << "the pipeline arm fires forever";
}
// The same three bits, moved by the OTHER event that changes what a pipeline draws: a
// stage program's relink. The stage set does not move at all here - only the link version
// the composite cache is keyed on, which is what makes GetProgramForDraw build a new one.
TEST_F(TrackerWalk, ARelinkOfAStageProgramFiresTheProgramBits) {
Vector<Uint> names;
Ctx().GenProgramPipelineNames(1, names);
ASSERT_EQ(names.size(), 1u);
Ctx().CreateProgramPipelineObject(names[0]);
Ctx().BindProgramPipelineObject(names[0]);
const auto& pipeline = Ctx().GetBoundProgramPipeline();
ASSERT_TRUE(pipeline != nullptr);
const Uint vertex = Ctx().CreateProgram();
const SharedPtr<MG_State::GLState::ProgramObject> stage = Ctx().GetProgramObject(vertex);
ASSERT_TRUE(stage != nullptr);
pipeline->SetStageProgram(MobileGL::ShaderStage::Vertex, stage);
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
// A real relink, through the entry point glLinkProgram drives. It fails - the
// program has no shaders attached - and that is deliberate: Link()'s PROLOGUE is where
// the link-observable versions are bumped, before any early-out, precisely so that
// every memo keyed on them reads stale from the instant the relink is enqueued.
stage->Link();
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShaderBindings), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants), 0u);
EXPECT_EQ(Walk(), 0u);
}
// AND THE HANDOVER, which is the shape an application actually writes: a program is in
// use, a pipeline is bound underneath it, and glUseProgram(0) hands the draw to the
// pipeline (GL 4.6 core 7.4). The program in use wins while there is one - so the bits
// must move when the SOURCE changes - and the pipeline must drive them afterwards.
TEST_F(TrackerWalk, UseProgramZeroLeavesTheBoundPipelineDrivingTheProgramBits) {
Vector<Uint> names;
Ctx().GenProgramPipelineNames(1, names);
ASSERT_EQ(names.size(), 1u);
Ctx().CreateProgramPipelineObject(names[0]);
Ctx().BindProgramPipelineObject(names[0]);
const auto& pipeline = Ctx().GetBoundProgramPipeline();
ASSERT_TRUE(pipeline != nullptr);
const Uint installed = Ctx().CreateProgram();
Ctx().UseProgram(installed);
ASSERT_TRUE(Ctx().GetCurrentProgram() != nullptr);
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
Ctx().UseProgram(0);
const Uint32 handover = Walk();
EXPECT_NE(handover & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u)
<< "the draw's program source changed and bit 6 did not fire";
ASSERT_EQ(Walk(), 0u);
// The pipeline is the source now, so a stage change has to reach the same bits.
const Uint vertex = Ctx().CreateProgram();
pipeline->SetStageProgram(MobileGL::ShaderStage::Vertex, Ctx().GetProgramObject(vertex));
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShader), 0u)
<< "with no program in use the bound pipeline has to drive the program family";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShaderBindings), 0u);
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants), 0u);
}
// P4a FABLE SEAM F-1. set_sampler_views is resolved for the PROGRAM IN USE (the sampler
// uniform's type picks which of a unit's targets is the view), and bit 12's shutter read
// only the texture-content aggregate and the bind generation - so `glUseProgram(P1); draw;
// glUseProgram(P2); draw` never re-emitted the set and the record went on describing P1's
// units. A program switch alone, with no bind and no texture change, has to fire it.
TEST_F(TrackerWalk, AProgramSwitchAloneFiresTheSamplerViewBit) {
const Uint first = Ctx().CreateProgram();
const Uint second = Ctx().CreateProgram();
Ctx().UseProgram(first);
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
Ctx().UseProgram(second);
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u)
<< "the view set is resolved for the program in use and a glUseProgram alone did not "
"re-emit it (F-1)";
EXPECT_EQ(Walk(), 0u) << "the widened shutter fires forever";
}
// The other input F-1 added: the params aggregate. SamplerEmit.h drops a unit's view to
// null when SamplesAsIncompleteTexture says so, and that predicate reads the effective
// sampler's filters and the level range - a glTexParameteri that completes a texture fired
// bit 13 and left the view entry null.
TEST_F(TrackerWalk, ATextureParameterAloneFiresTheSamplerViewBit) {
const auto& tex = Ctx().CreateTextureObject(1, TextureTarget::Texture2D);
ASSERT_TRUE(tex != nullptr);
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
tex->SetMaxLevel(4);
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewSamplerViews), 0u)
<< "completeness is a view-set input and a parameter change did not re-resolve it";
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewSamplers), 0u);
EXPECT_EQ(Walk(), 0u);
}
// P4a FABLE SEAM F-2 (and E's SD-4, which is this bit through a buffer image). Bit 14's
// plain-program arm mixed GetImageUnitVersion() ALONE - a per-program counter that two
// programs routinely share, 0 == 0 for any pair that never moved an image unit through
// glUniform1i - so a glUseProgram between them fired nothing and set_shader_images' window
// stayed the previous program's. The pipeline arm already mixed stageLinks; the plain arm
// now mixes the same identity bit 6 reads.
TEST_F(TrackerWalk, AProgramSwitchBetweenEqualImageUnitCountersFiresTheShaderImageBit) {
const Uint first = Ctx().CreateProgram();
const Uint second = Ctx().CreateProgram();
ASSERT_EQ(Ctx().GetProgramObject(first)->GetImageUnitVersion(),
Ctx().GetProgramObject(second)->GetImageUnitVersion())
<< "the premise of this case is two programs whose image-unit counters are equal";
Ctx().UseProgram(first);
Walk();
ASSERT_EQ(Walk(), 0u) << "the fixture did not reach a steady state";
Ctx().UseProgram(second);
const Uint32 dirty = Walk();
EXPECT_NE(dirty & MGPipeDirtyBit(MGPipeDirty::NewShaderImages), 0u)
<< "set_shader_images' window is the program's and a glUseProgram alone did not "
"re-emit it (F-2)";
EXPECT_EQ(Walk(), 0u) << "the widened shutter fires forever";
}
// ===================================================================================
// set_vertex_attrib_defaults' payload (P2 brief D10)
// ===================================================================================
+23
View File
@@ -296,3 +296,26 @@ target_link_libraries(
)
gtest_discover_tests(ProgramArtifactsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
# P4a: the archive SERIALIZER over the same VisitFields tables, beside the header's own suite.
# Its target and this registration are the CONTRACT commit's, like every other suite whose
# contents a later package extends. Every case is a visible SKIP in a pull build, because the
# codec is push-only - the root CMakeLists.txt appends it inside `if (MOBILEGL_PIPE_PUSH)` -
# and a vanishing test would make `ctest -N` differ between the pull and push trees.
add_executable(
ProgramArtifactsCodecTest
ProgramArtifactsCodecTest.cpp
)
target_include_directories(ProgramArtifactsCodecTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
ProgramArtifactsCodecTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
gtest_discover_tests(ProgramArtifactsCodecTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
@@ -0,0 +1,375 @@
// MobileGL - MobileGL/MG_Test/Program/ProgramArtifactsCodecTest.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 reflection ARCHIVE's serializer (P4a): create_shader_state's payload is per-stage SPIR-V
// plus LinkArtifacts + SpirvArtifacts, whole structs, and the codec is what turns them into
// bytes. In monolith it is never called on the hot path - the two structs ride beside the
// record through the apply entry point's companion pointers - so THIS SUITE plus the verify
// lane's round trip are the only things that exercise it until a transport exists.
//
// WHAT IT HAS TO PIN, and each of the three is a different failure:
// * a fully populated archive survives a round trip FIELD BY FIELD, including both of
// XfbVarying's spellings (the GL name AND the block instance / member / element triple)
// and every one of TypeFacts' twenty members - a codec that dropped one would be invisible
// until a backend read a reflection answer that had quietly become zero;
// * a TRUNCATED stream is refused rather than guessed at;
// * a VERSION or struct-size mismatch is refused rather than deserialised into a layout this
// build does not have.
//
// AND ONE PROPERTY THAT IS NOT ABOUT BYTES AT ALL: LinkArtifacts has 58 members and its
// VisitFields table visits 57. The 58th is the live SharedPtr<glslang::TProgram>, which is
// null for every archived instance by construction and points into an arena no archive owns.
// The codec has no arm for it, and the count is asserted here because that is the only place
// it can be: VisitFields needs an instance, and these structs carry strings, vectors and maps,
// so no static_assert can walk them.
//
// Every case is a visible SKIP in a pull build rather than a vanishing test, so `ctest -N`
// stays name-for-name identical between the pull and the push trees.
#include <gtest/gtest.h>
#include "Includes.h"
#if MOBILEGL_PIPE_PUSH
#include <MG_State/GLState/ProgramState/ProgramArtifactsCodec.h>
#endif
using namespace MobileGL;
#if MOBILEGL_PIPE_PUSH
using namespace MobileGL::MG_State::GLState;
namespace {
TypeFacts MakeTypeFacts() {
// Every member set to something that is NOT its default, so a field the codec skips
// reads back as the default and the comparison names it.
TypeFacts facts{};
facts.isArray = true;
facts.isSizedArray = true;
facts.isMatrix = true;
facts.isVector = true;
facts.isOpaque = true;
facts.isTexture = true;
facts.isImage = true;
facts.isDouble = true;
facts.isVoid = true;
facts.isBuffer = true;
facts.isPatch = true;
facts.hasIndex = true;
facts.hasFormat = true;
facts.vectorSize = 3;
facts.matrixCols = 4;
facts.matrixRows = 2;
facts.layoutIndex = 7;
facts.layoutFormat = 0x8814u;
facts.layoutMatrix = 1;
facts.basicType = 11;
return facts;
}
ResourceReflection MakeReflection(const char* name, Int location) {
ResourceReflection reflection{};
reflection.name = name;
reflection.glDefineType = GL_FLOAT_VEC4;
reflection.offset = 16;
reflection.size = 4;
reflection.index = 2;
reflection.counterIndex = 3;
reflection.arrayStride = 16;
reflection.topLevelArraySize = 5;
reflection.topLevelArrayStride = 32;
reflection.binding = 6;
reflection.location = location;
reflection.stages = 0x3u;
reflection.arraySize = 8;
reflection.type = MakeTypeFacts();
return reflection;
}
XfbVarying MakeXfbVarying() {
XfbVarying varying{};
// BOTH SPELLINGS. `name` is the GL one ("Block.member"), which the interface queries
// and the ESSL driver-side capture list need; the triple below is what a SPIR-V
// backend needs instead, because the decoration target is the block's instance
// variable and the member index inside it.
varying.name = "Captured.position";
varying.type = GL_FLOAT_VEC3;
varying.size = 2;
varying.bufferIndex = 1;
varying.offsetBytes = 12;
varying.byteSize = 24;
varying.packedOffsetBytes = 8;
varying.blockInstanceName = "capturedInstance";
varying.blockName = "Captured";
varying.blockMemberIndex = 1;
varying.blockMemberElement = 3;
return varying;
}
LinkArtifacts MakeLinkArtifacts() {
LinkArtifacts link{};
link.uniformReflection = {MakeReflection("uColour", 0), MakeReflection("uMatrix", 1)};
link.blockReflection = {MakeReflection("Block", -1)};
link.pipeInputReflection = {MakeReflection("inPosition", 0)};
link.pipeOutputReflection = {MakeReflection("outColour", 0)};
link.lastStageIsFragment = true;
link.computeLocalSize = {8u, 4u, 2u};
link.uniformIndexByName = {{"uColour", 0}, {"uMatrix", 1}};
link.attribs = {"inPosition", "inNormal"};
link.attribTypes = {GL_FLOAT_VEC3, GL_FLOAT_VEC3};
link.linkedFragDataLocation = {{"outColour", 0u}};
link.linkedFragDataIndex = {{"outColour", 1u}};
link.glUniformIndexToTProgram = {0, 1};
link.tProgramUniformIndexToGl = {0, 1};
link.glBlockIndexToTProgram = {0};
link.tProgramBlockIndexToGl = {-1};
link.glUniformBlockIndexToBlock = {0};
link.blockIndexToGlUniformBlock = {0};
link.linkedExplicitUniformLocations = {{"uColour", 3}};
link.uniformLocations = {{"uColour", 0u}, {"uMatrix", 4u}};
link.writtenUniformLocationBits = {0x5ull};
link.writtenUniformIndexBits = {0x3ull};
link.writtenUniformIndices = {0u, 1u};
link.uniformIndexInTProgram = {0, 1};
link.uniformSamplerOrImageUnitIndex = {-1, 2};
link.explicitOpaqueUniformBindings = {{"uSampler", 5u}};
link.uniformBlockIndexByName = {{"Block", 0u}};
link.uniformBlockBinding = {2};
link.shaderStorageBlockBinding = {{"Storage", 1}};
link.storageBlocksWithoutBinding = {"Storage"};
link.uniformBlocksWithoutBinding = {"Block"};
link.activeUniformCount = 2u;
link.usesReservedNumSamples = true;
link.maxUniformLocation = 4u;
link.uniformNameMaxLength = 9;
link.attribInNameMaxLength = 11;
link.uniformBlockNameMaxLength = 6;
link.infoLog = "linked with warnings";
link.linkStatus = true;
link.xfbVaryings = {MakeXfbVarying()};
link.xfbInterfaceNames = {"gl_NextBuffer", "Captured.position"};
link.xfbStrides = {32u, 0u};
link.gsStripTriangles = {3u, 5u};
link.gsStripCaptureFixup = true;
link.gsInputPrimitive = GL_TRIANGLES;
link.tcsOutputVertices = 3;
link.gsOutputPrimitive = GL_TRIANGLE_STRIP;
link.gsMaxVertices = 12;
link.gsInvocations = 2;
link.tessGenMode = GL_QUADS;
link.tessGenSpacing = GL_FRACTIONAL_ODD;
link.tessGenVertexOrder = GL_CW;
link.tessGenPointMode = true;
link.xfbBufferMode = GL_SEPARATE_ATTRIBS;
link.xfbVaryingNameMaxLength = 18;
link.xfbNeedsScatteredCapture = true;
link.xfbPackedStride = 24u;
// The one glslang-typed member that DOES travel: a plain aggregate of a string,
// scalars and two vectors. The codec has a hand-written arm for it because
// ProgramArtifacts.h gives it no VisitFields table.
glslang::TIntermediate::TUniformInitializer initializer;
initializer.name = "uInitialised";
initializer.basicType = glslang::EbtInt;
initializer.vectorSize = 2;
initializer.matrixCols = 0;
initializer.matrixRows = 0;
initializer.arraySize = 3;
initializer.intValues = {1, 2, 3, 4, 5, 6};
initializer.floatValues = {};
link.uniformInitialValues.push_back(initializer);
return link;
}
SpirvArtifacts MakeSpirvArtifacts() {
SpirvArtifacts spirv{};
spirv.generatedSpirv = {{0x07230203u, 0x00010300u, 0u}, {0x07230203u, 0x00010300u, 1u}};
spirv.enableSpirvValidation = true;
spirv.uniformOffsets = {0u, 16u, kInvalidUniformOffset};
spirv.globalUboScratch = {1, 2, 3, 4, 5, 6, 7, 8};
spirv.reservedNumSamplesOffset = 32u;
spirv.spirvStatus = true;
spirv.nativeFloat64 = true;
spirv.pointSizeDemoted = true;
return spirv;
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
// The round trip, field by field. A re-encode equality alone would prove the codec is
// self-consistent and nothing else - a field it skips in BOTH directions round-trips
// perfectly - so the members are read back explicitly first, and the byte comparison is the
// catch-all underneath them.
TEST(ProgramArtifactsCodec, RoundTripsAFullyPopulatedArchive) {
#if MOBILEGL_PIPE_PUSH
const LinkArtifacts link = MakeLinkArtifacts();
const SpirvArtifacts spirv = MakeSpirvArtifacts();
Vector<Uint8> bytes;
EncodeProgramArtifacts(link, spirv, bytes);
ASSERT_FALSE(bytes.empty());
LinkArtifacts decodedLink;
SpirvArtifacts decodedSpirv;
ASSERT_TRUE(DecodeProgramArtifacts(bytes.data(), bytes.size(), decodedLink, decodedSpirv));
// The four reflection vectors, with their TypeFacts.
ASSERT_EQ(decodedLink.uniformReflection.size(), 2u);
EXPECT_EQ(decodedLink.uniformReflection[0].name, "uColour");
EXPECT_EQ(decodedLink.uniformReflection[1].location, 1);
EXPECT_EQ(decodedLink.uniformReflection[0].arrayStride, 16);
EXPECT_EQ(decodedLink.uniformReflection[0].stages, 0x3u);
EXPECT_TRUE(decodedLink.uniformReflection[0].type.isSizedArray);
EXPECT_EQ(decodedLink.uniformReflection[0].type.matrixCols, 4);
EXPECT_EQ(decodedLink.uniformReflection[0].type.layoutFormat, 0x8814u);
EXPECT_EQ(decodedLink.uniformReflection[0].type.basicType, 11);
ASSERT_EQ(decodedLink.blockReflection.size(), 1u);
ASSERT_EQ(decodedLink.pipeInputReflection.size(), 1u);
ASSERT_EQ(decodedLink.pipeOutputReflection.size(), 1u);
// BOTH XfbVarying SPELLINGS.
ASSERT_EQ(decodedLink.xfbVaryings.size(), 1u);
EXPECT_EQ(decodedLink.xfbVaryings[0].name, "Captured.position");
EXPECT_EQ(decodedLink.xfbVaryings[0].blockInstanceName, "capturedInstance");
EXPECT_EQ(decodedLink.xfbVaryings[0].blockName, "Captured");
EXPECT_EQ(decodedLink.xfbVaryings[0].blockMemberIndex, 1);
EXPECT_EQ(decodedLink.xfbVaryings[0].blockMemberElement, 3);
EXPECT_EQ(decodedLink.xfbVaryings[0].packedOffsetBytes, 8u);
// The maps, the set and the fixed array - the four container shapes the archive is made
// of, each with a reader that has to agree with its writer about the length prefix.
EXPECT_EQ(decodedLink.uniformIndexByName.size(), 2u);
EXPECT_EQ(decodedLink.uniformIndexByName.at("uMatrix"), 1);
EXPECT_EQ(decodedLink.shaderStorageBlockBinding.at("Storage"), 1);
EXPECT_EQ(decodedLink.storageBlocksWithoutBinding.count("Storage"), 1u);
EXPECT_EQ(decodedLink.uniformBlocksWithoutBinding.count("Block"), 1u);
EXPECT_EQ(decodedLink.computeLocalSize[0], 8u);
EXPECT_EQ(decodedLink.computeLocalSize[2], 2u);
// The glslang-typed aggregate, through the codec's one hand-written arm.
ASSERT_EQ(decodedLink.uniformInitialValues.size(), 1u);
EXPECT_EQ(decodedLink.uniformInitialValues[0].name, "uInitialised");
EXPECT_EQ(decodedLink.uniformInitialValues[0].basicType, glslang::EbtInt);
EXPECT_EQ(decodedLink.uniformInitialValues[0].arraySize, 3);
ASSERT_EQ(decodedLink.uniformInitialValues[0].intValues.size(), 6u);
EXPECT_EQ(decodedLink.uniformInitialValues[0].intValues[5], 6);
EXPECT_TRUE(decodedLink.uniformInitialValues[0].floatValues.empty());
// The scalars at the tail, which is where a length-prefix that drifted by one would first
// read as garbage rather than as a short read.
EXPECT_EQ(decodedLink.infoLog, "linked with warnings");
EXPECT_TRUE(decodedLink.linkStatus);
EXPECT_EQ(decodedLink.tessGenSpacing, static_cast<GLenum>(GL_FRACTIONAL_ODD));
EXPECT_TRUE(decodedLink.tessGenPointMode);
EXPECT_EQ(decodedLink.xfbPackedStride, 24u);
// SpirvArtifacts, including the nested vector of SPIR-V words and the sentinel offset.
ASSERT_EQ(decodedSpirv.generatedSpirv.size(), 2u);
ASSERT_EQ(decodedSpirv.generatedSpirv[0].size(), 3u);
EXPECT_EQ(decodedSpirv.generatedSpirv[1][2], 1u);
ASSERT_EQ(decodedSpirv.uniformOffsets.size(), 3u);
EXPECT_EQ(decodedSpirv.uniformOffsets[2], kInvalidUniformOffset);
EXPECT_EQ(decodedSpirv.globalUboScratch.size(), 8u);
EXPECT_EQ(decodedSpirv.reservedNumSamplesOffset, 32u);
EXPECT_TRUE(decodedSpirv.nativeFloat64);
EXPECT_TRUE(decodedSpirv.pointSizeDemoted);
// THE LIVE TProgram IS NEVER CARRIED and never reconstructed.
EXPECT_EQ(decodedLink.program, nullptr);
// The catch-all: re-encoding what came back has to produce the same bytes, which covers
// every member the explicit reads above do not name.
Vector<Uint8> reencoded;
EncodeProgramArtifacts(decodedLink, decodedSpirv, reencoded);
EXPECT_EQ(reencoded, bytes);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only";
#endif
}
// Negative control 1. Every prefix length is checked against the bytes that REMAIN, so a
// stream cut anywhere has to come back false with both outputs defaulted - never a partially
// filled archive, and never a resize driven by a count the stream cannot back.
TEST(ProgramArtifactsCodec, ATruncatedStreamIsRefusedNotGuessed) {
#if MOBILEGL_PIPE_PUSH
Vector<Uint8> bytes;
EncodeProgramArtifacts(MakeLinkArtifacts(), MakeSpirvArtifacts(), bytes);
ASSERT_GT(bytes.size(), 64u);
// Cut at a spread of points rather than one: the header, a length prefix, the middle of a
// string and the last byte all fail through different branches.
for (const SizeT cut : {SizeT{0}, SizeT{4}, SizeT{9}, bytes.size() / 3, bytes.size() / 2,
bytes.size() - 1}) {
LinkArtifacts link;
SpirvArtifacts spirv;
link.infoLog = "must be cleared";
EXPECT_FALSE(DecodeProgramArtifacts(bytes.data(), cut, link, spirv))
<< "a stream truncated at " << cut << " was accepted";
EXPECT_TRUE(link.infoLog.empty()) << "a refused decode left the output half-filled";
EXPECT_TRUE(link.uniformReflection.empty());
EXPECT_TRUE(spirv.generatedSpirv.empty());
}
// And 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.
Vector<Uint8> withTail = bytes;
withTail.push_back(0);
LinkArtifacts link;
SpirvArtifacts spirv;
EXPECT_FALSE(DecodeProgramArtifacts(withTail.data(), withTail.size(), link, spirv));
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only";
#endif
}
// Negative control 2. The version word and the struct-size echo are the two things a compiler
// cannot check: a struct that gained a field and a codec that did not would otherwise
// deserialise garbage into the tail of a reflection table. Both have to REFUSE.
TEST(ProgramArtifactsCodec, AVersionMismatchIsRefused) {
#if MOBILEGL_PIPE_PUSH
Vector<Uint8> bytes;
EncodeProgramArtifacts(MakeLinkArtifacts(), MakeSpirvArtifacts(), bytes);
ASSERT_GT(bytes.size(), 12u);
LinkArtifacts link;
SpirvArtifacts spirv;
ASSERT_TRUE(DecodeProgramArtifacts(bytes.data(), bytes.size(), link, spirv));
// The version word first: a reader that saw a format it does not know must not try to
// guess the layout.
Vector<Uint8> wrongVersion = bytes;
++wrongVersion[0];
EXPECT_FALSE(DecodeProgramArtifacts(wrongVersion.data(), wrongVersion.size(), link, spirv));
// Then the MGL_LINKARTIFACTS_SIZE echo, which is the half that catches a struct that grew
// under a codec that did not - the failure the four sizeof trip wires in
// ProgramArtifacts.h send an author here to fix.
Vector<Uint8> wrongSize = bytes;
++wrongSize[4];
EXPECT_FALSE(DecodeProgramArtifacts(wrongSize.data(), wrongSize.size(), link, spirv));
// A null pointer is refused rather than dereferenced.
EXPECT_FALSE(DecodeProgramArtifacts(nullptr, 0, link, spirv));
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only";
#endif
}
// The codec walks the VisitFields tables and nothing else, so what those tables visit IS the
// archive. LinkArtifacts has 58 members and its table visits 57: the 58th is the live glslang
// TProgram, which is null for every archived instance by construction and points into an arena
// no archive owns. A codec arm for it would be a use-after-free waiting for a cache hit.
TEST(ProgramArtifactsCodec, TheTablesVisitEveryMemberExceptTheLiveProgram) {
#if MOBILEGL_PIPE_PUSH
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<LinkArtifacts>(), 57u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<SpirvArtifacts>(), 8u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<ResourceReflection>(), 14u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<XfbVarying>(), 11u);
EXPECT_EQ(ProgramArtifactsVisitedFieldCount<TypeFacts>(), 20u);
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: the archive codec is push-only";
#endif
}
+407
View File
@@ -3129,6 +3129,177 @@ TEST(DirectGLESTextureSync, UnitMemoRefusesToDriveATwinFromAnotherTexture) {
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, 0);
}
namespace {
// What glTexParameteri actually reached the driver, and which backend texture was bound
// when it did. The G9 probe below is a WHITE-BOX assertion (ID-19): the parameter push is
// not observable through public GL without creating the very sampler view whose absence is
// the subject, so the observation is taken at the driver boundary instead.
struct TexParamCall {
GLuint texture;
GLenum pname;
GLint value;
};
MobileGL::Vector<TexParamCall>* g_texParamCalls = nullptr;
GLuint g_texParamBoundTexture = 0;
void TP_BindTexture(GLenum, GLuint texture) { g_texParamBoundTexture = texture; }
void TP_ActiveTexture(GLenum) {}
void TP_TexParameteri(GLenum, GLenum pname, GLint value) {
if (g_texParamCalls) {
g_texParamCalls->push_back({g_texParamBoundTexture, pname, value});
}
}
void TP_TexParameterf(GLenum, GLenum, GLfloat) {}
void TP_TexParameterfv(GLenum, GLenum, const GLfloat*) {}
void TP_PixelStorei(GLenum, GLint) {}
void TP_BindBuffer(GLenum, GLuint) {}
struct ScopedTexParamRecording {
explicit ScopedTexParamRecording(MobileGL::Vector<TexParamCall>& sink) {
g_texParamCalls = &sink;
g_texParamBoundTexture = 0;
}
~ScopedTexParamRecording() { g_texParamCalls = nullptr; }
ScopedTexParamRecording(const ScopedTexParamRecording&) = delete;
ScopedTexParamRecording& operator=(const ScopedTexParamRecording&) = delete;
};
} // namespace
// G9 AS A WHITE-BOX ASSERTION (gates review R1, ID-19), AND IT COVERS THE HALF THE PUBLIC-GL
// SCENARIO CANNOT.
//
// TextureParamsWithoutASamplerViewScenario catches "the parameters were EMITTED and marked
// synced but never applied": its observation is a sample, the sample creates the sampler view,
// and IsDrawSyncClean then skips the sync. What it cannot catch is a backend that merely DEFERS
// the apply to the first sampler view - the observation creates that view, the parameters land
// at that moment, and the case is green. Here the reading is taken while the texture is still
// attachment-only: nothing is ever bound to a unit, no sampler view is minted, and the
// assertion is that the parameter reached the driver ANYWAY.
//
// RED ON THE PRE-P4a BEHAVIOUR: with set_texture_params addressed by resource, this twin's
// parameter push no longer needs anything to be bound. A backend that reinstated the deferral -
// resolving the params through a sampler view, or gating the push on a unit binding - leaves
// the recording empty and this case fails, which is exactly the regression the scenario's
// self-repair hides.
//
// WHAT IT DOES NOT COVER, AND THE NEXT READER MUST NOT OVER-TRUST IT (review N-9): the probe
// drives SyncTextureParamsToBackend DIRECTLY, so the only deferral shape it can see is one
// INSIDE that function. A regression that gates the CALL on a sampler view existing - in
// SyncNeccessaryTextures, or in package E's per-unit walk - leaves this case green. That
// caller-level half is a scenario's job and the scenario is package F's (G9's scenario half,
// gates review R1); this is the backend-side probe R1 asked for and nothing wider.
TEST(DirectGLESTextureSync, AnAttachmentOnlyTexturesParametersReachTheDriverWithNoSamplerView) {
using namespace MobileGL;
ScopedDirectGLESTextureBindings scoped; // fresh GLContext + registry + binding caches
Vector<TexParamCall> params;
ScopedTexParamRecording recording(params);
auto functions = MG_Backend::DirectGLES::g_GLESFuncs;
functions.glBindTexture = TP_BindTexture;
functions.glActiveTexture = TP_ActiveTexture;
functions.glTexParameteri = TP_TexParameteri;
functions.glTexParameterf = TP_TexParameterf;
functions.glTexParameterfv = TP_TexParameterfv;
functions.glPixelStorei = TP_PixelStorei;
functions.glBindBuffer = TP_BindBuffer;
MG_Backend::DirectGLES::SetGLESFuncsTable(functions);
GLuint name = 0;
MG_Impl::GLImpl::GenTextures(1, &name);
const auto texture = MakeComplete2DTexture(name, 8);
ASSERT_NE(texture, nullptr);
// ATTACHMENT-ONLY from here on: the unit that specified it is released, so nothing in this
// test ever binds this texture for sampling and nothing mints a sampler view for it.
MG_Impl::GLImpl::BindTexture(GL_TEXTURE_2D, 0);
// The parameter that has to travel. Red -> Green is not any texture's default, so a driver
// that never hears about it is distinguishable from one that does.
texture->SetSwizzleParam(TextureSwizzleParam::Red, TextureSwizzleParam::Green);
auto& registry = MG_Backend::DirectGLES::TextureImpl::g_backendTextureObjects;
auto& twin = registry.GetOrCreate(texture);
if (!twin) {
twin = MakeShared<MG_Backend::DirectGLES::TextureImpl::BackendTextureObject>();
}
ASSERT_NE(twin, nullptr);
#if MOBILEGL_PIPE_PUSH
const MG_Pipe::MGPipeHandle res = registry.HandleOf(texture.get());
MG_Pipe::MGPipeHandle builtinSampler = MG_Pipe::kMGPipeNullHandle;
if (MG_Backend::DirectGLES::TextureResourceSubsystemEnabled()) {
// The handle arm reads the applier, so the applier is what this probe writes - which is
// also what makes it a white-box test rather than a scenario: no client emitter exists
// on this tree, and the point is Espryt's behaviour given a record, not the client's.
ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(res));
MG_Pipe::MGPResourceDesc desc{};
desc.Resource = res;
desc.Target = static_cast<Uint8>(MG_Pipe::MGPipeResourceTarget::Tex2D);
desc.StorageKind = static_cast<Uint8>(TextureStorageType::Mipmap);
desc.InternalFormat = static_cast<Uint32>(TextureInternalFormat::RGBA8);
desc.Width = 8;
desc.Height = 8;
desc.Depth = 1;
desc.ArrayLayers = 1;
desc.Levels = 1;
EXPECT_TRUE(MG_Pipe::MGPipeApplyResourceCreate(desc));
// Every ITextureObject owns a sampler object, so the built-in sampler CSO is not
// optional (a null one is Fatal{ProtocolCorruption} in the applier). It is a SAMPLER
// CSO and not a sampler VIEW - the distinction this case exists for.
auto samplerOwner = MakeShared<MG_State::GLState::SamplerObject>(0u);
builtinSampler = MG_Pipe::MGPipeSlots().Acquire(MG_Pipe::MGPipeKind::SamplerCso,
samplerOwner->GetLifetimeId());
ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(builtinSampler));
MG_Pipe::MGPSamplerDesc samplerDesc{};
samplerDesc.Cso = builtinSampler;
SamplerParameters samplerParams{};
MG_Pipe::MGPipeApplyCreateSamplerState(samplerDesc, &samplerParams);
MG_Pipe::MGPTextureParams pushed{};
pushed.Res = res;
pushed.BuiltinSampler = builtinSampler;
pushed.MaxLevel = 0;
pushed.Swizzle[0] = static_cast<Uint8>(TextureSwizzleParam::Green);
pushed.Swizzle[1] = static_cast<Uint8>(TextureSwizzleParam::Green);
pushed.Swizzle[2] = static_cast<Uint8>(TextureSwizzleParam::Blue);
pushed.Swizzle[3] = static_cast<Uint8>(TextureSwizzleParam::Alpha);
MG_Pipe::MGPipeApplySetTextureParams(pushed);
// THE OBSERVATION IS TAKEN WHILE THE TEXTURE IS STILL ATTACHMENT-ONLY: no sampler view
// record exists, and no sampler-view twin does either. This is the assertion the public
// scenario cannot make, because making it there would create the view.
EXPECT_EQ(MG_Backend::DirectGLES::SamplerViewImpl::g_backendSamplerViews.FindByHandle(
MG_Pipe::MGPipeSlots().FindByLifetimeId(MG_Pipe::MGPipeKind::SamplerViewCso,
texture->GetLifetimeId())),
nullptr)
<< "a sampler view was minted for a texture nothing sampled";
}
#endif
const SizeT before = params.size();
twin->SyncTextureParamsToBackend(texture);
Bool sawSwizzleR = false;
for (SizeT i = before; i < params.size(); ++i) {
if (params[i].pname == GL_TEXTURE_SWIZZLE_R) {
sawSwizzleR = true;
EXPECT_EQ(params[i].value, static_cast<GLint>(GL_GREEN))
<< "the swizzle reached the driver with the wrong value";
}
}
EXPECT_TRUE(sawSwizzleR)
<< "an attachment-only texture's GL_TEXTURE_SWIZZLE_R never reached the driver: the "
"parameter push is gated on something being bound, which is the deferral G9's public "
"scenario cannot observe";
#if MOBILEGL_PIPE_PUSH
if (!MG_Pipe::MGPipeHandleIsNull(builtinSampler)) {
MG_Pipe::MGPipeApplyDeleteSamplerState(MG_Pipe::MGPHandleOnly{builtinSampler});
MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::SamplerCso, builtinSampler);
}
#endif
}
TEST(DirectVulkanSanity, GraphicsSamplerFeedbackOnlyAliasesWritableOverlappingMip) {
using MobileGL::MG_Backend::DirectVulkan::UniformManager;
@@ -3954,6 +4125,238 @@ TEST(DirectGLESSlotTable, EverySwitchedOverKindResolvesItsTwinThroughTheHandleAr
table.ReleaseByHandle(second);
MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::Buffer, second);
}
// P4a: the EIGHTH table and the SIXTH kind - the sampler view, which is the only kind in
// the phase with no frontend object at all. MobileGL has no sampler-view class: GL binds a
// texture to a unit and the sampler uniform's type plus the two completeness predicates
// decide what the shader sees, gallium's one-view-per-slot IS that resolved form, and the
// resolution moves to the CLIENT (D-F3). So this table is handle-keyed only, like the
// buffer resource table above, and its twin owns no driver id whatsoever - what it holds is
// the server's memo of one resolved view plus the raw-depth-fetch substitution decision,
// which is one of the two backend post-processings ARCHITECTURE.md:206 keeps on the server.
//
// The handle is minted off the TEXTURE's lifetime id (D-F2: one view per ITextureObject),
// which is what makes HandleOf resolve at all - and it is legal precisely because the two
// kinds have separate slot spaces, so the same lifetime id names a Texture slot and a
// SamplerViewCso slot without either shadowing the other.
{
using MobileGL::MG_Backend::DirectGLES::SamplerViewImpl::g_backendSamplerViews;
using MobileGL::MG_Backend::DirectGLES::SamplerViewImpl::BackendSamplerViewObject;
auto& table = g_backendSamplerViews;
SharedPtr<ITextureObject> owner = MakeShared<TextureObject2D>(0u);
const MG_Pipe::MGPipeHandle first =
MG_Pipe::MGPipeSlots().Acquire(MG_Pipe::MGPipeKind::SamplerViewCso, owner->GetLifetimeId());
ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(first))
<< "SamplerViewCso: the client allocator minted no handle for a live texture's view";
// The same lifetime id, two kinds, two independent slot spaces.
const MG_Pipe::MGPipeHandle textureHandle =
MG_Pipe::MGPipeSlots().FindByLifetimeId(MG_Pipe::MGPipeKind::Texture, owner->GetLifetimeId());
EXPECT_FALSE(first == textureHandle)
<< "SamplerViewCso: the view handle and the texture handle are the same {slot, gen}, so "
"one kind's slot space is aliasing the other's";
auto& firstTwin = table.GetOrCreate(first);
firstTwin = MakeShared<BackendSamplerViewObject>();
firstTwin->SyncedSerial = 0xABCDEFull;
const BackendSamplerViewObject* const firstRaw = firstTwin.get();
EXPECT_EQ(table.FindByHandle(first), &firstTwin)
<< "SamplerViewCso: the handle does not address the twin GetOrCreate handed back";
EXPECT_EQ(table.HandleOf(owner.get()), first)
<< "SamplerViewCso: HandleOf did not resolve the view minted off this texture's "
"lifetime id, so a backend path that still arrives holding the object cannot find "
"its view";
const SharedPtr<BackendSamplerViewObject> released = table.ReleaseByHandle(first);
EXPECT_NE(released, nullptr) << "SamplerViewCso: delete_sampler_view found no twin to retire";
EXPECT_EQ(table.FindByHandle(first), nullptr)
<< "SamplerViewCso: the twin outlived its delete_sampler_view";
MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::SamplerViewCso, first);
auto successorOwner = MakeShared<TextureObject2D>(0u);
const MG_Pipe::MGPipeHandle second = MG_Pipe::MGPipeSlots().Acquire(
MG_Pipe::MGPipeKind::SamplerViewCso, successorOwner->GetLifetimeId());
ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(second)) << "SamplerViewCso";
EXPECT_EQ(second.Slot, first.Slot)
<< "SamplerViewCso: the freed slot was not handed back, so this walk did not exercise "
"the recycle it exists to test";
EXPECT_NE(second.Gen, first.Gen)
<< "SamplerViewCso: Gen did not move on slot reuse - the predecessor's handle would "
"resolve to the successor's view, which is the ABA the {slot, gen} key exists to stop";
EXPECT_EQ(table.FindByHandle(first), nullptr) << "SamplerViewCso: the STALE handle resolved";
auto& secondTwin = table.GetOrCreate(second);
EXPECT_EQ(secondTwin, nullptr)
<< "SamplerViewCso: a view at a recycled slot inherited its predecessor's memo, so the "
"raw-depth-fetch decision of a dead texture would be replayed for a live one";
secondTwin = MakeShared<BackendSamplerViewObject>();
EXPECT_NE(table.FindByHandle(second)->get(), firstRaw) << "SamplerViewCso";
// BACKWARD generations are REFUSED rather than adopted, exactly as they are on every
// other handle-keyed table (SlotTables.h:301-321). Forward is a recycle; adopting a
// backward one would retire the incumbent LIVE twin and then stamp the slot back to the
// dead view's generation.
//
// m-8: THE FIRST ASSERTION BELOW CANNOT DISTINGUISH THE TWO OUTCOMES and its message
// must not claim it does. GetOrCreate(handle) calls entry.backend.reset() before it
// stamps, so an ADOPTED backward generation yields a null BackendPtr& just as a refused
// one does. What separates refusal from adoption is the SECOND assertion: after an
// adoption the live twin at `second` is gone. The first is kept because a non-null there
// would mean the table handed back the incumbent's own twin under the dead handle, which
// is a third outcome and a worse one.
auto& stale = table.GetOrCreate(first);
EXPECT_EQ(stale, nullptr)
<< "SamplerViewCso: a stale handle was answered with the LIVE twin at its slot (this "
"assertion cannot tell a refusal from an adoption - the next one does)";
EXPECT_NE(table.FindByHandle(second), nullptr)
<< "SamplerViewCso: the live twin was destroyed by a handle from its slot's past - "
"the backward generation was ADOPTED rather than refused";
table.ReleaseByHandle(second);
MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::SamplerViewCso, second);
}
}
// P4a (ID-8): the backend death notice is the REDUNDANT SECOND PATH for every kind the client
// mints, and it must be IDEMPOTENT - the client's own helper emits the wire delete, raises this
// notice and frees the slot, in that order, so whichever of the two frees first wins and the
// other must find nothing and do nothing.
//
// Driven through the REAL consumer the backend installed rather than through a recording stub:
// what is under test is Espryt's OnFrontendStateObjectDestroyed arm, including the SamplerViewCso
// arm P4a adds, and a stub would prove only that the test can call itself.
TEST(DirectGLESSlotTable, ADeathNoticeForEveryP4aKindIsIdempotent) {
using namespace MobileGL;
using namespace MobileGL::MG_Backend::DirectGLES;
using namespace MobileGL::MG_State::GLState;
if (!EsprytSlotTablesEnabled()) {
GTEST_SKIP() << "the legacy arm keys twins on the frontend heap address and cannot answer "
"a death notice at all";
}
const MG_State::GLState::StateObjectDeathOps* ops = MG_State::GLState::GetStateObjectDeathOps();
ASSERT_NE(ops, nullptr) << "the backend installed no death-notice consumer";
ASSERT_NE(ops->OnDestroyed, nullptr);
// The sampler view first, because it is the arm P4a adds and the only kind whose notice
// names a lifetime id belonging to ANOTHER object (the texture the view was minted off).
{
using SamplerViewImpl::BackendSamplerViewObject;
using SamplerViewImpl::g_backendSamplerViews;
auto owner = MakeShared<TextureObject2D>(0u);
const Uint64 lifetimeId = owner->GetLifetimeId();
const MG_Pipe::MGPipeHandle view =
MG_Pipe::MGPipeSlots().Acquire(MG_Pipe::MGPipeKind::SamplerViewCso, lifetimeId);
ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(view));
g_backendSamplerViews.GetOrCreate(view) = MakeShared<BackendSamplerViewObject>();
ASSERT_NE(g_backendSamplerViews.FindByHandle(view), nullptr);
ops->OnDestroyed(MG_Pipe::MGPipeKind::SamplerViewCso, lifetimeId);
EXPECT_EQ(g_backendSamplerViews.FindByHandle(view), nullptr)
<< "the sampler-view notice left the twin behind, so the slot's next owner would "
"inherit a dead texture's resolved view";
EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(
MG_Pipe::MGPipeSlots().FindByLifetimeId(MG_Pipe::MGPipeKind::SamplerViewCso, lifetimeId)))
<< "the notice did not return the slot";
// The second path. The client's helper calls Free right after raising the notice, and a
// Free on a slot that is no longer live at that generation is a proven no-op
// (SlotAllocator.cpp:117-119) - so this is the shape that actually ships, twice over.
ops->OnDestroyed(MG_Pipe::MGPipeKind::SamplerViewCso, lifetimeId);
MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::SamplerViewCso, view);
ops->OnDestroyed(MG_Pipe::MGPipeKind::SamplerViewCso, lifetimeId);
EXPECT_EQ(g_backendSamplerViews.FindByHandle(view), nullptr);
// And the slot really is back: a successor gets it with a moved generation, which is
// the property a double free would break by skipping one.
auto successor = MakeShared<TextureObject2D>(0u);
const MG_Pipe::MGPipeHandle reused = MG_Pipe::MGPipeSlots().Acquire(
MG_Pipe::MGPipeKind::SamplerViewCso, successor->GetLifetimeId());
EXPECT_EQ(reused.Slot, view.Slot);
EXPECT_NE(reused.Gen, view.Gen);
MG_Pipe::MGPipeSlots().Free(MG_Pipe::MGPipeKind::SamplerViewCso, reused);
}
// The five kinds that DO have a frontend object: their notice is raised by the object's own
// destructor today and by the client's helper after P4a's client packages land, so it is
// delivered twice for one death. A second delivery must be a no-op rather than a second
// free - which is what would drop a successor's twin under it.
struct KindCase {
const char* name;
MG_Pipe::MGPipeKind kind;
Uint64 lifetimeId;
// m-9: the handle the object held before it died, so the ABA leg below can prove the
// slot came back AT A MOVED GENERATION. ID-8's third case ("a notice for a slot
// re-minted at a new generation") was covered for SamplerViewCso only; a double free
// skips a generation, and nothing here would have caught that for the other five.
MG_Pipe::MGPipeHandle handle;
};
Vector<KindCase> cases;
{
auto texture = MakeShared<TextureObject2D>(0u);
auto renderbuffer = MakeShared<RenderbufferObject>(0u);
auto framebuffer = MakeShared<FramebufferObject>(1u);
auto sampler = MakeShared<SamplerObject>(0u);
auto program = MakeShared<ProgramObject>(0u);
TextureImpl::g_backendTextureObjects.GetOrCreate(SharedPtr<ITextureObject>(texture));
RenderbufferImpl::g_backendRenderbufferObjects.GetOrCreate(renderbuffer);
FramebufferImpl::g_backendFramebufferObjects.GetOrCreate(framebuffer);
SamplerImpl::g_backendSamplerObjects.GetOrCreate(sampler);
PrgramImpl::g_backendProgramObjects.GetOrCreate(program);
const auto handleFor = [](MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) {
return MG_Pipe::MGPipeSlots().FindByLifetimeId(kind, lifetimeId);
};
cases.push_back({"Texture", MG_Pipe::MGPipeKind::Texture, texture->GetLifetimeId(),
handleFor(MG_Pipe::MGPipeKind::Texture, texture->GetLifetimeId())});
cases.push_back({"Renderbuffer", MG_Pipe::MGPipeKind::Renderbuffer, renderbuffer->GetLifetimeId(),
handleFor(MG_Pipe::MGPipeKind::Renderbuffer, renderbuffer->GetLifetimeId())});
cases.push_back({"Framebuffer", MG_Pipe::MGPipeKind::Framebuffer, framebuffer->GetLifetimeId(),
handleFor(MG_Pipe::MGPipeKind::Framebuffer, framebuffer->GetLifetimeId())});
cases.push_back({"SamplerCso", MG_Pipe::MGPipeKind::SamplerCso, sampler->GetLifetimeId(),
handleFor(MG_Pipe::MGPipeKind::SamplerCso, sampler->GetLifetimeId())});
cases.push_back({"ShaderCso", MG_Pipe::MGPipeKind::ShaderCso, program->GetLifetimeId(),
handleFor(MG_Pipe::MGPipeKind::ShaderCso, program->GetLifetimeId())});
for (const auto& one : cases) {
EXPECT_FALSE(MG_Pipe::MGPipeHandleIsNull(
MG_Pipe::MGPipeSlots().FindByLifetimeId(one.kind, one.lifetimeId)))
<< one.name << ": nothing was twinned, so this walk proves nothing";
}
// The real destructors run here and raise the first notice.
}
for (const auto& one : cases) {
EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(
MG_Pipe::MGPipeSlots().FindByLifetimeId(one.kind, one.lifetimeId)))
<< one.name << ": the object's own death did not return its slot";
// Second and third deliveries: nothing to resolve, nothing to free, no abort.
ops->OnDestroyed(one.kind, one.lifetimeId);
ops->OnDestroyed(one.kind, one.lifetimeId);
EXPECT_TRUE(MG_Pipe::MGPipeHandleIsNull(
MG_Pipe::MGPipeSlots().FindByLifetimeId(one.kind, one.lifetimeId)))
<< one.name << ": a redundant notice resurrected a mapping";
// m-9, THE ABA LEG: the slot really came back, and its generation MOVED. Two redundant
// notices plus the object's own death are three chances to free one slot twice, and a
// double free is invisible in every assertion above - it shows up here, as a successor
// handed the same {slot, gen} the dead object held, which is precisely the handle a
// surviving memo would still be naming.
auto successor = MakeShared<TextureObject2D>(0u);
const MG_Pipe::MGPipeHandle reused =
MG_Pipe::MGPipeSlots().Acquire(one.kind, successor->GetLifetimeId());
ASSERT_FALSE(MG_Pipe::MGPipeHandleIsNull(reused)) << one.name;
if (reused.Slot == one.handle.Slot) {
EXPECT_NE(reused.Gen, one.handle.Gen)
<< one.name << ": the slot came back at the SAME generation, so a memo holding "
"the dead object's handle would resolve to its successor's twin";
}
MG_Pipe::MGPipeSlots().Free(one.kind, reused);
}
}
// The two-holder fix, end to end through the REAL Texture registry and the REAL destructor:
@@ -4372,6 +4775,10 @@ TEST(DirectGLESSlotTable, AGenerationBehindTheLiveTwinIsRefusedRatherThanAdopted
GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH";
}
TEST(DirectGLESSlotTable, ADeathNoticeForEveryP4aKindIsIdempotent) {
GTEST_SKIP() << "the {slot, gen} twin table is compiled only under MOBILEGL_PIPE_PUSH";
}
TEST(DirectGLESBufferDrawProbe, ALiveHostMapKeepsTheHandleArmProbeDirtyBetweenTwoDraws) {
GTEST_SKIP() << "the handle-keyed resource table is compiled only under MOBILEGL_PIPE_PUSH";
}

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