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
94 changed files with 23849 additions and 391 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.
+58
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
@@ -516,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()
+103 -1
View File
@@ -335,7 +335,10 @@ namespace MobileGL::MG_Config {
// 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
// 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)
@@ -411,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
+103
View File
@@ -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 {
@@ -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,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
+604 -14
View File
@@ -26,36 +26,626 @@
// 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, and it is 0 until the emitter below
// has a body. 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.
inline constexpr Uint64 kMGPipeWiredFramebufferSubsystem = 0;
// 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
// ---------------------------------------------------------------------------------
// set_framebuffer_state. STUB AT THE CONTRACT COMMIT: it emits nothing and returns 0
// payload bytes, so the validate point's ladder has its final shape and the package that
// fills this in never edits PipeFill.cpp.
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) {
(void)ctx;
return 0;
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 - the applier's object records survive a make-current and
// re-publishing them would move their serials for nothing.
void Reset() {}
// 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() {
+124 -4
View File
@@ -33,23 +33,143 @@
// 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 {
// STUB AT THE CONTRACT COMMIT: emits nothing, returns 0 payload bytes.
// 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) {
(void)ctx;
return 0;
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);
}
void Reset() {}
// 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() {
+284 -13
View File
@@ -875,15 +875,197 @@ namespace MobileGL::MG_Pipe {
using MG_State::GLState::RenderbufferObject;
using MG_State::GLState::SamplerObject;
// THE SAME PAIR `wants()` APPLIES TO EVERY EMISSION at the validate point, and it is
// THE FOUR FAMILIES P4a MIGRATES, as one mask, so the consumer rule below is stated
// once instead of four times. It is deliberately NOT kMGPipeSubsystemsMigratedAtP4a
// (which is 0x1fff, every bit through P4a): the rule belongs to the families this
// phase adds and to no earlier one.
inline constexpr Uint64 kMGPipeP4aFamilySubsystems =
kMGPipeSubsystemFramebuffer | kMGPipeSubsystemTextureResources |
kMGPipeSubsystemSamplers | kMGPipeSubsystemPrograms;
// AND THE THIRD HALF, WHICH IS P3a's SECOND ONE: HAS A BACKEND REGISTERED THE CONSUMER?
//
// `MGPipeResourceSubsystemEnabled()` (above, ~:612) is bit 7 AND
// `MGPipeGetResourceOps() != nullptr`, and the second conjunct is not decoration - it is
// what keeps P3a's buffers on the legacy pull path under a backend that registers no
// table. DirectVulkan (Magma) is exactly that backend: it registers no
// MGPipeResourceOps and has none of P4a's twins. Without this conjunct the four P4a
// families emitted there anyway, the applier ACCEPTED every record, the emitters cleared
// their per-level dirty flags on that acceptance (D-D5 as amended by ID-18 M3), and
// Magma's legacy upload path then found nothing left to upload: 66 texture-upload-shaped
// DirectVulkan integration-gpu cases red on the push build at the default mask, with the
// pull build 966/966 green (ID-39).
//
// ALL FOUR FAMILIES RIDE THE ONE SIGNAL, and the reason is D-D1: a texture and a
// renderbuffer are RESOURCE rows - they travel on P3a's own resource_create /
// resource_respecify / resource_subdata catalogue, whose consumer IS this table - so the
// texture family's gate is P3a's gate by construction. The other three name texture
// handles and cannot be live without it (MGPSurface::Res is a texture or renderbuffer
// handle, MGPBoundView::Texture and MGPImageView::Res are texture handles, and
// MGPTextureParams is addressed by one), so they follow. There is no fifth signal to
// invent and no per-family registration to add: a backend that consumes P4a records
// consumes resource rows first.
//
// A BACKEND THAT REGISTERS ONE IS UNAFFECTED. DirectGLES (Espryt) registers the table
// at RegisterBufferBackendOps, unconditionally and at bring-up, so every predicate
// below answers exactly what it answered before this commit.
//
// THE REGISTER/UNREGISTER WINDOW IS THE SAME ONE P3a LIVES WITH, and it is closed the
// same way: UnregisterBufferBackendOps nulls the table at context teardown and the
// re-register happens at the next MakeCurrent, so an object born in that window never
// publishes a create and latches Published = false - after which the family's own
// self-healing create on the next respecify (TextureEmit.h ~:576 / ~:709, the shape
// MGPipeEmitResourceRespecify above uses for buffers) publishes it. Nothing here needs
// to remember the window.
Bool P4aFamilyHasItsConsumer(Uint64 subsystem) {
return (subsystem & kMGPipeP4aFamilySubsystems) == 0 ||
MGPipeGetResourceOps() != nullptr;
}
// ================================================================================
// AND THE FOURTH HALF: D-K2's DEPENDENCY TABLE, ON THE CLIENT (S-3, ID-41)
// ================================================================================
//
// THE DEFECT THIS CLOSES. Espryt's four `Resolve<Family>SubsystemArm()` functions
// (Managers.cpp ~:3595-3745) REFUSE a family whose D-K2 dependency bit is clear and run
// the legacy arm instead - the shape ResolveVertexInputSubsystemArm's bit-8-requires-
// bit-7 refusal set as the precedent. That refusal is a BACKSTOP and it cannot restore a
// correct picture on its own, because the client's emission was gated on the operator's
// mask ALONE: at 0x7ff (bit 10 set, bit 11 clear) the client emitted the whole texture
// family, the applier accepted it, the emitter cleared each level's per-level dirty flag
// on that acceptance (D-D5 as amended by ID-18 M3) - and then the server refused bit 10
// and ran the legacy path, which found nothing left to upload. 438/491 on the DirectGLES
// integration lane, the same 47 texture-upload failures ID-39 saw on Magma for the
// consumer-less version of exactly this mistake.
//
// So the rule is the SAME "nothing at all, not less" rule as the consumer conjunct
// above: with a dependency unmet the client emits NOTHING for that family and the legacy
// pull path runs untouched, on both sides of the boundary.
//
// THE TABLE IS WRITTEN ONCE, HERE, and every one of its rows is the client mirror of the
// refusal Espryt already implements, bit for bit and non-transitively - the two must say
// the SAME thing, because a client that withheld more than the server refuses would
// leave the server's handle arm live with no records to read, and a client that withheld
// less is the defect above.
struct P4aFamilyDependencyRow {
Uint64 Family; // exactly one bit, and it is one of kMGPipeP4aFamilySubsystems
Uint64 Requires; // the bits MOBILEGL_PIPE_PUSH must ALSO carry for it to be live
};
inline constexpr P4aFamilyDependencyRow kMGPipeP4aFamilyDependencies[] = {
// BIT 9 REQUIRES BIT 10. Every MGPSurface::Res in a set_framebuffer_state record
// names a Texture or a Renderbuffer handle, and only bit 10 populates those two slot
// tables (Managers.cpp ResolveFramebufferSubsystemArm).
{kMGPipeSubsystemFramebuffer, kMGPipeSubsystemTextureResources},
// BIT 10 REQUIRES BIT 7 - a buffer texture's MGPResourceDesc::BufferForTexBuffer
// names a Buffer handle and only bit 7 puts twins in the resource slot table (D-D1,
// ResolveTextureResourceSubsystemArm's first row) - AND BIT 11, which is D-K2's
// FOURTH row (ID-14/ID-15): MGPTextureParams::BuiltinSampler is a SamplerCso HANDLE,
// only bit 11 mints sampler CSOs (c0b's four unconditional mints deliberately
// exclude it), and the applier's verdict for a null one is Fatal{ProtocolCorruption}
// rather than a decline. The brief's original "bit 10 without 11 is fine" is
// WITHDRAWN for P4a as built.
{kMGPipeSubsystemTextureResources,
kMGPipeSubsystemResources | kMGPipeSubsystemSamplers},
// BIT 11 REQUIRES BIT 10. Every MGPBoundView::Texture and every MGPImageView::Res
// names a Texture handle and only bit 10 populates that slot table; without it every
// per-unit lookup would miss and the walk would `continue` WITHOUT unbinding
// (ResolveSamplerSubsystemArm). With the row above this is SYMMETRIC: bits 10 and 11
// are one arm with two switches, and the only two masks that reach either handle arm
// are "both set" and "neither set".
{kMGPipeSubsystemSamplers, kMGPipeSubsystemTextureResources},
// BIT 12 DEPENDS ON NOTHING, and that is a ROW rather than an absence so the table
// covers the four families exhaustively (the static_assert below): a ShaderCso handle
// names no texture and no buffer, the archive rides beside the record as a companion
// pointer, and the extra inputs the server specialises on are read from state the
// backend already holds (ResolveProgramSubsystemArm).
{kMGPipeSubsystemPrograms, 0},
};
// THE MIRROR PAIRS THAT STAY FINE, said out loud rather than left as an absence, because
// an unreachable branch that says something different is how the reachable one drifts
// (Managers.cpp's own words at :2377-2381) - and because the table is only trustworthy if
// what it does NOT contain was decided rather than forgotten:
// - bit 10 set, bit 9 clear: FINE. The legacy FBO sync reaches the texture twin through
// SyncTextureObjectToBackend, which dispatches to the handle arm by itself.
// - bit 11 set, bit 9 clear: FINE, for the same reason - a sampler view names a texture,
// never a framebuffer.
// - bit 7 set, bit 10 clear: FINE, and it is P3a's shipped configuration.
// - bit 12 set with any or none of 9/10/11: FINE, per the last row.
// - bit 10 set, bit 11 clear (and its mirror) is NOT fine and is the row above; this is
// the one sentence in the brief that P4a as built withdrew.
constexpr Uint64 P4aFamilyDependencyBits(Uint64 subsystem) {
Uint64 required = 0;
for (const P4aFamilyDependencyRow& row : kMGPipeP4aFamilyDependencies) {
if ((subsystem & row.Family) != 0) required |= row.Requires;
}
return required;
}
// The table covers the four families this phase migrates and nothing else, so a fifth
// family added to kMGPipeP4aFamilySubsystems without a row here does not silently inherit
// "depends on nothing".
constexpr Uint64 P4aFamilyDependencyTableCoverage() {
Uint64 covered = 0;
for (const P4aFamilyDependencyRow& row : kMGPipeP4aFamilyDependencies) covered |= row.Family;
return covered;
}
static_assert(P4aFamilyDependencyTableCoverage() == kMGPipeP4aFamilySubsystems,
"every P4a family needs a D-K2 dependency row, even an empty one");
static_assert(P4aFamilyDependencyBits(kMGPipeSubsystemFramebuffer) ==
kMGPipeSubsystemTextureResources,
"bit 9 requires bit 10");
static_assert(P4aFamilyDependencyBits(kMGPipeSubsystemTextureResources) ==
(kMGPipeSubsystemResources | kMGPipeSubsystemSamplers),
"bit 10 requires bit 7 and bit 11");
static_assert(P4aFamilyDependencyBits(kMGPipeSubsystemSamplers) ==
kMGPipeSubsystemTextureResources,
"bit 11 requires bit 10");
static_assert(P4aFamilyDependencyBits(kMGPipeSubsystemPrograms) == 0, "bit 12 depends on nothing");
// No family may depend on itself: a row that did would be unfalsifiable (its own bit is
// set by the time the conjunct is evaluated) and would read as a dependency nobody has.
static_assert((P4aFamilyDependencyBits(kMGPipeSubsystemFramebuffer) &
kMGPipeSubsystemFramebuffer) == 0 &&
(P4aFamilyDependencyBits(kMGPipeSubsystemTextureResources) &
kMGPipeSubsystemTextureResources) == 0 &&
(P4aFamilyDependencyBits(kMGPipeSubsystemSamplers) &
kMGPipeSubsystemSamplers) == 0,
"a D-K2 row must not name its own family");
// The default mask carries every dependency, so the shipped arm is unchanged by all of
// this - the table only ever narrows a HAND-PICKED A/B mask.
static_assert((kMGPipeSubsystemsMigratedAtP4a &
P4aFamilyDependencyBits(kMGPipeP4aFamilySubsystems)) ==
P4aFamilyDependencyBits(kMGPipeP4aFamilySubsystems),
"the P4a phase mask must satisfy every dependency it declares");
// IT IS THE RUNTIME BIT THAT IS TESTED, NOT THE OTHER FAMILY'S LIVENESS, and that is
// deliberate: Espryt's resolvers classify their arms from MOBILEGL_PIPE_PUSH alone, so
// testing anything else here would make the two sides disagree at some mask - which is
// the failure this whole commit is about, one level up. The mask is passed in rather than
// read, so the walk's single read of MG_Config::Features.PipePush stays the one read a
// whole validate point resolves against.
Bool P4aFamilyDependenciesAreSet(Uint64 subsystem, Uint64 pushMask) {
const Uint64 required = P4aFamilyDependencyBits(subsystem);
return (pushMask & required) == required;
}
// THE SAME QUADRUPLE `wants()` APPLIES TO EVERY EMISSION at the validate point, and it is
// deliberately the same predicate rather than a second copy of it: the operator's
// per-subsystem A/B bit in MOBILEGL_PIPE_PUSH, AND this build having WIRED the family
// at all. The second half is the family's own kMGPipeWired*Subsystem constant, which
// lives in the family's emit header and is 0 until the commit that gives the emitter
// its body - so a client path that lands before its emitter does is inert by
// construction rather than by everyone remembering to check.
// per-subsystem A/B bit in MOBILEGL_PIPE_PUSH, this build having WIRED the family
// at all, AND - for a P4a family - a backend having registered the consumer and every
// D-K2 dependency bit of the family being set. The second half is the family's own
// kMGPipeWired*Subsystem constant, which lives in the family's emit header and is 0 until
// the commit that gives the emitter its body - so a client path that lands before its
// emitter does is inert by construction rather than by everyone remembering to check; the
// third is P4aFamilyHasItsConsumer above and the fourth is P4aFamilyDependenciesAreSet.
Bool FamilyIsLive(Uint64 subsystem, Uint64 wired) {
return (MG_Config::Features.PipePush & subsystem) != 0 && (wired & subsystem) != 0;
const Uint64 pushMask = MG_Config::Features.PipePush;
return (pushMask & subsystem) != 0 && (wired & subsystem) != 0 &&
P4aFamilyHasItsConsumer(subsystem) &&
P4aFamilyDependenciesAreSet(subsystem, pushMask);
}
// ---- THE FAMILY SEAM ----
@@ -1039,6 +1221,18 @@ namespace MobileGL::MG_Pipe {
PublicationLatch().NoteUnpublished(kind, handle);
}
// THE GATE ITSELF, AS AN OBSERVABLE (ID-39, widened by S-3 / ID-41). Every P4a birth hook
// below and every `wants()` row in the walk resolve through FamilyIsLive /
// P4aFamilyHasItsConsumer / P4aFamilyDependenciesAreSet, and none of the three is reachable
// from a test - so this is the one door a unit case has onto the answer, and it is the SAME
// expression rather than a second copy of it. A subsystem outside kMGPipeP4aFamilySubsystems
// answers the pair the P2/P3a families have always answered (its consumer conjunct is
// vacuous and its dependency set is empty), which is what makes "nothing that emits today
// changes" checkable instead of asserted.
Bool MGPipeP4aFamilyEmits(Uint64 subsystem, Uint64 wired) {
return FamilyIsLive(subsystem, wired);
}
void MGPipeMintTextureHandle(ITextureObject& texture) {
MGPipeSlots().Acquire(MGPipeKind::Texture, texture.GetLifetimeId());
}
@@ -1061,10 +1255,12 @@ namespace MobileGL::MG_Pipe {
MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.EmitResourceCreate(texture); });
}
void MGPipeEmitTextureResourceRespecify(ITextureObject& texture) {
void MGPipeEmitTextureResourceRespecify(ITextureObject& texture, MGPipeTextureRespecifyScope scope,
Uint32 uploadTarget, Uint32 level) {
if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return;
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.EmitResourceRespecify(texture); });
MGPipeTextureEmitterInstance(),
[&](auto& emitter) { emitter.EmitResourceRespecify(texture, scope, uploadTarget, level); });
}
void MGPipeEmitTextureParams(ITextureObject& texture) {
@@ -1094,6 +1290,21 @@ namespace MobileGL::MG_Pipe {
[&](auto& emitter) { emitter.EmitRenderbufferRespecify(renderbuffer); });
}
void MGPipeNoteTextureBoundAs(MGPipeHandle texture, Uint32 bindBit) {
// Not gated on FamilyIsLive: the mask is client state (see the declaration), and the
// emitter gates the emission it causes.
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(),
[&](auto& emitter) { emitter.NoteTextureBoundAs(texture, static_cast<Uint16>(bindBit)); });
}
void MGPipeNoteTextureImageBound(ITextureObject& texture) {
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(MGPipeTextureEmitterInstance(), [&](auto& emitter) {
emitter.NoteTextureBoundAs(emitter.AcquireTexture(texture.GetLifetimeId(), &texture),
static_cast<Uint16>(kMGPipeBindShaderImage));
});
}
void MGPipeEmitSamplerCsoCreate(SamplerObject& sampler) {
if (!FamilyIsLive(kMGPipeSubsystemSamplers, kMGPipeWiredSamplerSubsystem)) return;
ForwardWhenWired<kMGPipeWiredSamplerSubsystem>(
@@ -1174,11 +1385,24 @@ namespace MobileGL::MG_Pipe {
}
} // namespace
// THE EMITTER IS TOLD BETWEEN THE WIRE DELETE AND THE FREE (P4a final review C-2), for
// every kind that keeps client state under a handle: a texture's drain entries, pointer,
// cache reference and latches; a renderbuffer's entry; a framebuffer's Named latch; a
// sampler view's and a shader CSO's record memo. Before this the six helpers freed the slot
// and told nobody, so the texture emitter kept the freed ITextureObject* and the level on
// the drain list, and `glTexImage2D; glDeleteTextures; <verb>` called a virtual on freed
// memory from the next validate point. The forward is the P3a shape
// (MGPipeEmitVertexElementsDestroyAndFree's emitter.NoteRecordDestroyed) applied to the
// five P4a kinds that have an entry to retire; the content-addressed sampler CSO keeps
// none per object (its death is the cache's LRU, ID-17). Unconditional in a push build,
// like the mints: the entries exist whether or not the family bit is set.
Bool MGPipeEmitSamplerViewCsoDestroyAndFree(Uint64 lifetimeId) {
const MGPipeHandle handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerViewCso, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::SamplerViewCso, handle, &MGPipeApplyDeleteSamplerView);
ForwardWhenWired<kMGPipeWiredSamplerSubsystem>(
MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); });
// THE NOTICE IS RAISED FOR THIS KIND TOO, and the reason it once was not is wrong:
// NotifyStateObjectDestroyed takes a KIND and a lifetime id, not an object
// (StateObjectDeathNotice.h - one entry point for every kind rather than one ops table
@@ -1198,6 +1422,11 @@ namespace MobileGL::MG_Pipe {
const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::Texture, handle, &MGPipeApplyResourceDestroy);
// The emitter retires its entry while the handle still resolves (C-2): the drain list
// drops the dead texture's levels, the raw pointer goes, the built-in sampler's cache
// reference is given back, the latches and the sticky mask are cleared.
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteTextureDied(handle); });
NotifyAndFree(MGPipeKind::Texture, lifetimeId, handle);
// THE SAMPLER VIEW DIES WITH ITS TEXTURE, because it is minted off the same lifetime
// id: one SamplerViewCso per ITextureObject (D-F2), re-issued on the same handle
@@ -1234,6 +1463,8 @@ namespace MobileGL::MG_Pipe {
MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::Renderbuffer, handle, &MGPipeApplyResourceDestroy);
ForwardWhenWired<kMGPipeWiredTextureSubsystem>(
MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteRenderbufferDied(handle); });
NotifyAndFree(MGPipeKind::Renderbuffer, lifetimeId, handle);
return published;
}
@@ -1258,6 +1489,8 @@ namespace MobileGL::MG_Pipe {
// whatever it owed", which for a framebuffer is the death notice this just raised.
const MGPipeHandle handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Framebuffer, lifetimeId);
ForwardWhenWired<kMGPipeWiredFramebufferSubsystem>(
MGPipeFramebufferEmitterInstance(), [&](auto& emitter) { emitter.NoteFramebufferDied(handle); });
NotifyAndFree(MGPipeKind::Framebuffer, lifetimeId, handle);
return false;
}
@@ -1267,6 +1500,11 @@ namespace MobileGL::MG_Pipe {
MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerCso, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::SamplerCso, handle, &MGPipeApplyDeleteSamplerState);
// NOTHING TO RETIRE IN AN EMITTER FOR THIS KIND, stated rather than implied: a sampler
// CSO is content-addressed and belongs to a value, so no emitter keeps an entry under
// a SamplerObject's handle - the cache's entries are keyed by value and reference
// count, and the death of a bound sampler object releases its unit's reference at the
// next bind_sampler_states pass (SamplerEmit.h's reconciliation).
NotifyAndFree(MGPipeKind::SamplerCso, lifetimeId, handle);
return published;
}
@@ -1283,6 +1521,8 @@ namespace MobileGL::MG_Pipe {
MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId);
const Bool published =
EmitDeleteIfPublished(MGPipeKind::ShaderCso, handle, &MGPipeApplyDeleteShaderState);
ForwardWhenWired<kMGPipeWiredProgramSubsystem>(
MGPipeProgramEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); });
NotifyAndFree(MGPipeKind::ShaderCso, lifetimeId, handle);
return published;
}
@@ -2171,7 +2411,7 @@ namespace MobileGL::MG_Pipe {
// would be a second copy of that map in the only path that runs, and mis-gating a bit
// in it would pass every test the map has.
//
// FOUR CONDITIONS, AND THE WIRED MASK IS ONE OF THEM. `kMGPipeWiredSubsystems` is the
// FIVE CONDITIONS, AND THE WIRED MASK IS ONE OF THEM. `kMGPipeWiredSubsystems` is the
// OR of the per-family constants each emit header defines, and the whole ownership
// design rests on it MEANING what the headers, this file and the result files all say
// it means: an emitter runs only once the commit that gave it a body set its family's
@@ -2181,11 +2421,28 @@ namespace MobileGL::MG_Pipe {
// measure an arm nobody thinks is on - and the mirror error is worse: a family that
// lands its body and forgets the constant would emit nothing and look broken. The
// P2/P3a bits are all in the mask, so nothing that emits today changes.
//
// AND THE FIFTH IS P4aFamilyHasItsConsumer (ID-39), the same conjunct FamilyIsLive
// applies to every birth hook: a P4a family whose records nothing on this backend
// consumes emits NOTHING, so the legacy pull path runs exactly as it does on the pull
// build. It is written here rather than folded into kMGPipeWiredSubsystems because the
// wired mask is a property of the BUILD - a constexpr an emit header sets - and this is
// a property of the RUNNING BACKEND, and collapsing the two would make a bisect that
// lands between them unreadable. The P2/P3a bits are outside kMGPipeP4aFamilySubsystems,
// so the conjunct is true for every one of them and nothing that emits today changes.
//
// AND THE SIXTH IS P4aFamilyDependenciesAreSet (S-3 / ID-41), the client half of D-K2:
// a family one of whose dependency bits the operator left clear emits NOTHING here for
// the same reason - the server REFUSES that family and runs its legacy arm, and an
// emission the server refuses is an emission whose acceptance already cleared a frontend
// dirty flag the legacy arm still owed. Same table, same four families, one place.
const Uint64 pushMask = MG_Config::Features.PipePush;
const auto wants = [&](MGPipeDirty bit) {
const Uint64 subsystem = MGPipeSubsystemForDirty(bit);
return subsystem != 0 && (pushMask & subsystem) != 0 &&
(kMGPipeWiredSubsystems & subsystem) != 0 &&
P4aFamilyHasItsConsumer(subsystem) &&
P4aFamilyDependenciesAreSet(subsystem, pushMask) &&
(dirty & MGPipeDirtyBit(bit)) != 0;
};
Uint64 payloadBytes = 0;
@@ -2250,10 +2507,17 @@ namespace MobileGL::MG_Pipe {
payloadBytes += EmitShaderState(*ctx);
}
// The texture drain has no dirty bit over it (see its definition); it is gated on the
// subsystem bit and on this build having wired the family at all, which is the same
// pair `wants()` applies to every other emission.
// subsystem bit, on this build having wired the family at all, on a backend having
// registered the consumer and on D-K2's dependency bits for the family being set, which
// is the same quadruple `wants()` applies to every other emission. The last two are the
// whole of ID-39 and of S-3 on the path where they mattered most: the drain is what
// clears a level's dirty flags on acceptance, so a drain that ran against an applier no
// backend reads is exactly how Magma lost its texel uploads, and a drain that ran at a
// mask whose bit 11 or bit 7 is clear is how Espryt lost them at 0x7ff and 0x5ff.
if ((pushMask & kMGPipeSubsystemTextureResources) != 0 &&
(kMGPipeWiredSubsystems & kMGPipeSubsystemTextureResources) != 0) {
(kMGPipeWiredSubsystems & kMGPipeSubsystemTextureResources) != 0 &&
P4aFamilyHasItsConsumer(kMGPipeSubsystemTextureResources) &&
P4aFamilyDependenciesAreSet(kMGPipeSubsystemTextureResources, pushMask)) {
payloadBytes += DrainTextureSubData(*ctx);
}
if (wants(MGPipeDirty::NewSamplerViews)) {
@@ -2326,8 +2590,15 @@ namespace MobileGL::MG_Pipe {
// the very fields the migration just took over.
const MGPipeFieldEmitter emitter = kMGPipeFieldEmittedBy[i];
const Uint64 subsystem = SubsystemForEmitter(emitter);
// P4aFamilyHasItsConsumer and P4aFamilyDependenciesAreSet are in this conjunction for
// the reason they are in `wants()`: "supplied" means A CALL WENT OUT CARRYING THIS
// FIELD, and on a backend with no consumer - or at a mask that leaves one of the
// family's D-K2 dependency bits clear - no P4a call went out at all, so withholding
// the pull here would leave the field unfilled at the very verb that reads it.
const Bool supplied = subsystem != 0 && (subsystem & kMGPipeWiredSubsystems) != 0 &&
(pushMask & subsystem) != 0 &&
P4aFamilyHasItsConsumer(subsystem) &&
P4aFamilyDependenciesAreSet(subsystem, pushMask) &&
EmittedCallSuppliesTheWholeField(field) &&
(applierDerives || AppliedWithoutDerivation(field));
if (!supplied) MGPipeFillAccess::CopyField(inputs, *ctx, field);
+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.
//
+410 -8
View File
@@ -36,38 +36,440 @@
// 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 {
// 0 until the emitters below have bodies; see FramebufferEmit.h's note.
inline constexpr Uint64 kMGPipeWiredProgramSubsystem = 0;
// 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;
}
// STUB AT THE CONTRACT COMMIT: emits nothing, returns 0 payload bytes.
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) {
(void)ctx;
return 0;
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) {
(void)ctx;
return 0;
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;
}
void Reset() {}
// 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() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+128 -12
View File
@@ -349,6 +349,18 @@ namespace MobileGL::MG_Pipe {
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(
@@ -357,7 +369,16 @@ 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
@@ -401,6 +422,10 @@ namespace MobileGL::MG_Pipe {
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;
@@ -412,17 +437,88 @@ namespace MobileGL::MG_Pipe {
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();
@@ -470,13 +566,24 @@ namespace MobileGL::MG_Pipe {
// firing costs one extra push; under-firing renders stale, and this file's own
// rule is that under-firing is the dangerous direction.
//
// A RENDERBUFFER RESPECIFY IS STILL INVISIBLE HERE, and deliberately so:
// RenderbufferObject's SetInternalFormat / AllocateStorage / SetSamples bump no
// version and raise no notice, so re-storaging an ALREADY-ATTACHED renderbuffer
// moves neither half of this shutter. That hole is closed by emitting
// resource_respecify straight from the storage entry point - not by widening this
// shutter and not by adding a version counter to RenderbufferObject, which would
// resize the pull build's object and break G1.
// 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
@@ -491,8 +598,16 @@ namespace MobileGL::MG_Pipe {
ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion())),
m_readFramebufferBind.Observe(
ctx.GetFramebufferBindingSlot(FramebufferTarget::Read).GetVersion()));
now[Index(MGPipeDirty::NewSamplerViews)] =
MGPipeMixShutter(textureContent, ctx.GetTextureBindGeneration());
// 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
@@ -515,8 +630,9 @@ namespace MobileGL::MG_Pipe {
now[Index(MGPipeDirty::NewSamplers)] = MGPipeMixShutter(
MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration()),
ctx.GetTextureBindGeneration());
now[Index(MGPipeDirty::NewShaderImages)] =
MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages);
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)] =
+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
+19 -10
View File
@@ -299,11 +299,16 @@
/* 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. */ \
/* 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) \
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) */ \
@@ -387,11 +392,12 @@
// 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 two rows. Both entries below are bit answers that are plainly
// true - glUseProgram is what moves the object bit 6's shutter reads through, and
// glBindVertexArray is what moves the object bit 5's shutter reads through - and the write
// analysis cannot say so, because each of them reaches, BY NAME, a body that writes a member
// with no m_ prefix:
// 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
@@ -403,13 +409,16 @@
// 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 9c is what proves a marked row still needs the mark, and control 18 is what fails
// the moment either of these becomes decidable and the mark outlives its reason.
// 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 are the first that carry a bit answer.
// 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
+87 -9
View File
@@ -300,7 +300,30 @@ namespace MobileGL::MG_Pipe {
Uint32 StorageFlags; // glBufferStorage flags
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
@@ -312,12 +335,62 @@ namespace MobileGL::MG_Pipe {
// 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 {
@@ -1100,14 +1173,19 @@ namespace MobileGL::MG_Pipe {
// 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 - not the whole vector, and not the redefined level either.
// This REFINES the level-scoped clear: identical storage fields clear NOTHING. (The
// - 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.)
// - The stored descriptor's BindMask and ImageBindableHint ARE updated - BindMask is
// sticky and therefore ORed, never replaced - and 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.
// 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
+145 -17
View File
@@ -852,6 +852,30 @@ namespace MobileGL::MG_Pipe {
void PinNoLiveHostWrites(const MGPipeResourceRecord&, MGPipeHandle, const char*) {}
#endif
#if MOBILEGL_PIPE_VERIFY
// P5's pin, the same shape and for the same reason. The respecify SCOPE now has a wire
// carrier (MGPResourceDesc's HasRespecifiedLevel + the pair) and no producer: every
// descriptor P5 builds is whole-resource, and the per-level scope still arrives the old
// way, as the trailing MGPRespecifiedLevel* this function does not look at.
//
// The two must not disagree, and when a later package wires the carrier it will set the
// fields at a call site that also still passes the pointer - so the first thing that can
// go wrong is exactly one of the two moving. A verify build refuses to let that arrive
// unannounced, because a descriptor that says "whole resource" while the pointer says
// "level 1" drops every other level's pending upload with nothing saying so.
void PinWholeResourceRespecifyScope(const MGPResourceDesc& desc, MGPipeHandle res, const char* call) {
if (MGPipeRespecifyIsWholeResource(desc)) return;
MGP_TRIP_WIRE_REPORT("MGPipe: " MGP_TRIP_WIRE_TAG("PipeRespecifyScope")
" %s {slot=%u, gen=%u}: the descriptor carries a per-level respecify "
"scope (target=%u, level=%u), and no path in this phase may set one",
call, res.Slot, res.Gen,
static_cast<unsigned>(MGPipeRespecifiedUploadTargetOf(desc)),
static_cast<unsigned>(MGPipeRespecifiedLevelOf(desc)));
}
#else
void PinWholeResourceRespecifyScope(const MGPResourceDesc&, MGPipeHandle, const char*) {}
#endif
// The one gate every content-carrying buffer write goes through. resource_subdata and
// buffer_subdata_resident differ only in which backend hook takes the bytes and in the
// fact that one of them is allowed to be absent, so a second copy of this arithmetic
@@ -1094,6 +1118,32 @@ namespace MobileGL::MG_Pipe {
void MGPipeSetResourceOps(const MGPipeResourceOps* ops) { g_resourceOps = ops; }
const MGPipeResourceOps* MGPipeGetResourceOps() { return g_resourceOps; }
namespace {
// P4a's BELT (ID-39). See MGPipeApplierState::RefusedNoConsumer for the whole argument;
// in one line: acceptance is a contract with the client, and accepting a record on a
// backend that consumes none of them makes the emitter clear a dirty flag the legacy
// pull path still owed.
//
// IT IS THE SAME SIGNAL THE CLIENT'S GATE READS, deliberately - g_resourceOps is the
// table a backend installs at its own bring-up and uninstalls at teardown, and
// MGPipeApplierReset does NOT touch it (see there), so the gate cannot flap between a
// make-current and the emissions that follow it. The two therefore agree on every path
// except the one this exists for: an emitter called directly, without passing PipeFill.
//
// EVERY CALLER PLACES IT AFTER THE RECORD'S OWN SHAPE CHECKS AND BEFORE ANYTHING MOVES.
// A malformed record is Fatal{ProtocolCorruption} whether or not anything would have
// read it - a trip wire that fires only on some backends is a trip wire nobody can
// trust - so the refusal is the LAST thing asked and the first thing that stops the
// write. The three unit-set entry points are the exception and say so at their call
// site: ApplyUnitWindow validates and writes in one step, so the question has to be
// asked in front of it.
Bool NoP4aConsumer() {
if (g_resourceOps != nullptr) return false;
++g_applier.RefusedNoConsumer;
return true;
}
} // namespace
void MGPipeApplierReset() {
g_applier.RenderStateCsos.clear();
g_applier.BoundRenderStateCso = kMGPipeNullHandle;
@@ -1123,6 +1173,7 @@ namespace MobileGL::MG_Pipe {
g_applier.RefusedResourceCalls = 0;
g_applier.RefusedVertexInputCalls = 0;
g_applier.RefusedObjectCalls = 0;
g_applier.RefusedNoConsumer = 0;
g_applier.BoundVertexElements = kMGPipeNullHandle;
g_applier.VertexBuffers = {};
g_applier.VertexBufferStart = 0;
@@ -1553,6 +1604,15 @@ namespace MobileGL::MG_Pipe {
kMGPipeMaxResourceSlots);
return false;
}
// P4a's belt, and only over the P4a half of this call: the BUFFER row is P3a's and its
// consumer question is the frontend's (MGPipeResourceSubsystemEnabled), which has
// already answered it before the emission - a buffer record can only arrive here on a
// backend that registered the table, so asking again would be a second copy of that
// rule in the one path that runs. A texture or renderbuffer create declined here leaves
// the client's publication latch at false, which is what makes the next respecify's
// self-healing create do the right thing if a table appears later.
if (desc.Target != kMGPipeResourceTargetBuffer && NoP4aConsumer()) return false;
*record = MGPipeResourceRecord{};
record->Gen = desc.Resource.Gen;
record->Live = true;
@@ -1583,9 +1643,16 @@ namespace MobileGL::MG_Pipe {
desc.Resource.Slot, desc.Resource.Gen, desc.GlNameForDiag, desc.Target);
return false;
}
// P4a's belt, for MGPipeApplyResourceCreate's reason and in front of the resolution
// rather than after it: with no consumer no create was ever accepted, so resolving
// first would report the absence as RefusedResourceCalls - the counter that means "a
// seam defect" - for a state that is by design.
if (desc.Target != kMGPipeResourceTargetBuffer && NoP4aConsumer()) return false;
MGPipeResourceRecord* record = ResolveResourceIn(*table, "resource_respecify", desc.Resource);
if (record == nullptr) return false;
PinNoLiveHostWrites(*record, desc.Resource, "resource_respecify");
PinWholeResourceRespecifyScope(desc, desc.Resource, "resource_respecify");
// IS THIS A REDEFINITION AT ALL? Asked BEFORE the descriptor is replaced, because the
// stored one is the only thing there is to compare against (ID-18 M4). See
@@ -1625,23 +1692,28 @@ namespace MobileGL::MG_Pipe {
// the arm this set exists for) -> glTexImage2D(1, data), which under a blanket
// clear destroys level 0's entry before anything ever uploaded it.
//
// - and a METADATA update (ID-18 M4) drops NOTHING, whatever `level` says. It is the
// third arm and it refines the first two rather than contradicting them: the rule
// is "the uploads against the storage this call REPLACES go with it", and a call
// whose storage-defining fields all equal the stored descriptor replaces no
// storage, so no level's coordinate system has moved and every pending box is still
// described in the space it was accumulated in. B re-emits the descriptor when a
// sticky bind bit moves, which can land between a glTexSubImage2D and the sync that
// consumes it; eating those texels there would be C1's bug with a different
// trigger, and just as silent.
// - and a METADATA update (ID-18 M4) with a NULL level drops NOTHING. It refines the
// whole-resource arm rather than contradicting it: the rule is "the uploads against
// the storage this call REPLACES go with it", and a call whose storage-defining
// fields all equal the stored descriptor replaces no storage, so no level's
// coordinate system has moved and every pending box is still described in the
// space it was accumulated in. B re-emits the descriptor when a sticky bind bit
// moves - with a null level, deliberately - which can land between a
// glTexSubImage2D and the sync that consumes it; eating those texels there would be
// C1's bug with a different trigger, and just as silent.
//
// - A NAMED LEVEL IS DROPPED WHETHER OR NOT THE DESCRIPTOR MOVED (P4a final review
// C-1, refining wire's W11 clause). The level pointer is the CALLER's statement that
// it reallocated that level, and the descriptor cannot contradict it: a non-base
// level redefined at a new size moves no descriptor field at all (the descriptor
// carries the base extent and the level count), so "identical storage fields" says
// nothing about that level's coordinate system, and a box kept against the old
// level would be uploaded past the end of the new one. The client's mask republish
// passes null, so this arm can never eat a standing upload on its behalf.
//
// A buffer never has a pending upload at all, so all three arms are inert for P3a's
// half - which is also why a buffer is never classified as metadata-only (below).
if (metadataOnly) {
// nothing to drop, deliberately.
} else if (level == nullptr) {
record->PendingUploads.clear();
} else {
if (level != nullptr) {
// The keys are unique by AccumulatePendingUpload's construction - it looks for the
// pair before it appends - so this erases at most one entry and stops.
for (auto it = record->PendingUploads.begin(); it != record->PendingUploads.end(); ++it) {
@@ -1649,6 +1721,10 @@ namespace MobileGL::MG_Pipe {
record->PendingUploads.erase(it);
break;
}
} else if (metadataOnly) {
// nothing to drop, deliberately.
} else {
record->PendingUploads.clear();
}
// resource_respecify is the catalogue's only kNeedsAck call, and the per-record half
@@ -1711,6 +1787,10 @@ namespace MobileGL::MG_Pipe {
static_cast<Uint32>(MGPipeSubDataUploadTargetOf(record.Target)));
return false;
}
// P4a's belt, AFTER the target fault above and before the upload is accumulated: this
// is the call whose acceptance clears a level's dirty flags (D-D5 / ID-18 M3), so it is
// the one that turned "no consumer" into lost texels on Magma.
if (NoP4aConsumer()) return false;
return ApplyTextureUpload(record, bytes, regions);
}
@@ -2200,6 +2280,14 @@ namespace MobileGL::MG_Pipe {
// The surfaces' Res handles are not resolved either: an attachment PINS its texture,
// and in monolith the frontend's own SharedPtr is that keep-alive, so a refusal here
// would be enforcing a lifetime rule monolith cannot need and split has not defined.
// P4a's belt, after the record's own shape checks and before the table is touched. This
// entry point is the ONE the client can reach without passing PipeFill's gate -
// GL_Framebuffer.cpp's PipePublishFramebufferByName calls the emitter directly at the
// fifteen DSA sites - so on a backend with no consumer this is where those records
// stop. RefusedNoConsumer and not RefusedObjectCalls: the framebuffer family's counter
// contract (see the header) is that no framebuffer call ever moves that one.
if (NoP4aConsumer()) return;
MGPipeFramebufferRecord* record =
RecordAt(g_applier.FramebufferRecords, state.Fbo.Slot, kMGPipeMaxFramebufferSlots);
// Unreachable: the bound was checked above, before anything moved. The null check is
@@ -2311,6 +2399,9 @@ namespace MobileGL::MG_Pipe {
desc.Cso.Slot, desc.Cso.Gen, kMGPipeMaxSamplerCsoSlots);
return;
}
// P4a's belt, after the blob rule and the slot bound and before the record moves.
if (NoP4aConsumer()) return;
MGPipeSamplerCsoRecord& record = *recordAt;
// A CREATE STARTS THE RECORD OVER AND LEAVES Serial AT 0; A RE-ISSUE ON A LIVE IDENTITY
// COUNTS UP. The first half is what stops a recycled slot contributing one field of its
@@ -2367,6 +2458,9 @@ namespace MobileGL::MG_Pipe {
view.Cso.Slot, view.Cso.Gen, kMGPipeMaxSamplerViewSlots);
return;
}
// P4a's belt, after the slot bound and before the record moves.
if (NoP4aConsumer()) return;
MGPipeSamplerViewRecord& record = *recordAt;
// RE-ISSUING ON THE SAME HANDLE IS HOW A RESTRICTION CHANGE TRAVELS - a view is
// identity-addressed one per texture object, minted off that object's lifetime id, and
@@ -2410,7 +2504,12 @@ namespace MobileGL::MG_Pipe {
record->Gen = gen;
}
void MGPipeApplySetTextureParams(const MGPTextureParams& params) {
Bool MGPipeApplySetTextureParams(const MGPTextureParams& params) {
// P4a's belt, and FIRST here because this call's first act is a resolution: with no
// consumer no texture create was accepted, so resolving would report the absence as
// RefusedObjectCalls - the counter that means a seam defect - for the designed state.
if (NoP4aConsumer()) return false;
// ADDRESSED BY RESOURCE AND BY NOTHING ELSE, which is the whole point of the call: 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 the
@@ -2418,7 +2517,7 @@ namespace MobileGL::MG_Pipe {
// moment the parameters move, whether or not anything is bound.
MGPipeResourceRecord* record =
ResolveObject(g_applier.TextureResources, "set_texture_params", params.Res);
if (record == nullptr) return;
if (record == nullptr) return false;
// EVERY ITextureObject OWNS A SamplerObject, so the built-in sampler CSO is not
// optional and a null handle is not "no sampler" - it is a record that would have the
@@ -2431,7 +2530,7 @@ namespace MobileGL::MG_Pipe {
" set_texture_params {slot=%u, gen=%u, glName=%u}: the record names no "
"built-in sampler CSO, and every texture object owns one",
params.Res.Slot, params.Res.Gen, record->Desc.GlNameForDiag);
return;
return false;
}
// AND THE CSO IT NAMES IS NOT RESOLVED. The sampler subsystem is its own bit and may be
// clear while the texture bit is set, so a record that names a CSO this applier has not
@@ -2446,6 +2545,7 @@ namespace MobileGL::MG_Pipe {
// bytes are CARRIED, never cleared here: the server ORs them into its own flags and
// clears its own copy, and the client never clears a server flag.
++record->ParamsSerial;
return true;
}
// The three of them, and NO STAGE DIMENSION on any of them: MobileGL's texture-unit space
@@ -2458,7 +2558,16 @@ namespace MobileGL::MG_Pipe {
// unit with no texture carries a null resource. None of the three is resolved against a
// record either - a set is WORKING STATE, the records it names are OBJECT state, and the
// backend resolves the pair at its own sync point where both are current.
//
// P4a's belt sits IN FRONT of ApplyUnitWindow on all three, and this is the one place it
// is not last: ApplyUnitWindow validates the window and writes it in the same step, so
// there is no point between the two to stand at. The cost is that on a backend with no
// consumer a malformed window is declined rather than Fatal - which is the right trade the
// one way round it can be made, because that backend would never have been handed the
// window at all (the client's gate stops it) and the shipped configuration that DOES
// consume these still trips the wire.
void MGPipeApplySetSamplerViews(const MGPSamplerViews& hdr, const MGPBoundView* tail) {
if (NoP4aConsumer()) return;
if (!ApplyUnitWindow("set_sampler_views", hdr.Start, hdr.Count, hdr.ContentHash, tail,
g_applier.BoundSamplerViews, g_applier.SamplerViewStart,
g_applier.SamplerViewCount)) {
@@ -2468,6 +2577,7 @@ namespace MobileGL::MG_Pipe {
}
void MGPipeApplyBindSamplerStates(const MGPSamplerStates& hdr, const MGPipeHandle* tail) {
if (NoP4aConsumer()) return;
if (!ApplyUnitWindow("bind_sampler_states", hdr.Start, hdr.Count, hdr.ContentHash, tail,
g_applier.BoundSamplerStates, g_applier.SamplerStateStart,
g_applier.SamplerStateCount)) {
@@ -2482,6 +2592,7 @@ namespace MobileGL::MG_Pipe {
// sent and recast on the server - the record carries the application's format, and the
// bind-format recast that turns a GL_RG32F bind into something 19 of 26 non-core
// formats on Adreno will accept is the backend's, not this applier's.
if (NoP4aConsumer()) return;
if (!ApplyUnitWindow("set_shader_images", hdr.Start, hdr.Count, hdr.ContentHash, tail,
g_applier.BoundShaderImages, g_applier.ShaderImageStart,
g_applier.ShaderImageCount)) {
@@ -2552,6 +2663,10 @@ namespace MobileGL::MG_Pipe {
PinProgramArchiveRoundTrip(desc, *link, *spirv);
#endif
// P4a's belt, after the blob rule, the slot bound and the verify round trip - all three
// are checks on the RECORD and stay honest on every backend - and before it is stored.
if (NoP4aConsumer()) return;
MGPipeShaderCsoRecord& record = *recordAt;
// A RE-ISSUE ON THE SAME HANDLE IS HOW A RELINK TRAVELS: the handle is minted per
// frontend program and Gen moves only on slot reuse, so an existing record of the same
@@ -2583,6 +2698,11 @@ namespace MobileGL::MG_Pipe {
void MGPipeApplyBindShaderState(const MGPHandleOnly& handle) {
MOBILEGL_ASSERT(handle.Kind == static_cast<Uint32>(MGPipeKind::ShaderCso),
"bind_shader_state on kind %u", handle.Kind);
// P4a's belt, and first for set_texture_params' reason: with no consumer this applier
// holds no shader CSO record, so the resolution below would report the designed state
// as RefusedObjectCalls. The null-handle unbind is behind it too - a binding this
// applier never made is not one it may clear.
if (NoP4aConsumer()) return;
// The null handle is legal and means "nothing bound", which is a GL state and not an
// error; a DEAD handle leaves the previous binding untouched and is counted, which is
// bind_render_state's precedent for the same question.
@@ -2639,6 +2759,7 @@ namespace MobileGL::MG_Pipe {
void MGPipeApplySetDrawProgram(const MGPHandleOnly& handle) {
MOBILEGL_ASSERT(handle.Kind == static_cast<Uint32>(MGPipeKind::ShaderCso),
"set_draw_program on kind %u", handle.Kind);
if (NoP4aConsumer()) return; // P4a's belt, for MGPipeApplyBindShaderState's reason.
if (MGPipeHandleIsNull(handle.Handle)) {
g_applier.DrawProgram = kMGPipeNullHandle;
++g_applier.ProgramBindingSerial;
@@ -2653,6 +2774,7 @@ namespace MobileGL::MG_Pipe {
void MGPipeApplySetDispatchProgram(const MGPHandleOnly& handle) {
MOBILEGL_ASSERT(handle.Kind == static_cast<Uint32>(MGPipeKind::ShaderCso),
"set_dispatch_program on kind %u", handle.Kind);
if (NoP4aConsumer()) return; // P4a's belt, for MGPipeApplyBindShaderState's reason.
if (MGPipeHandleIsNull(handle.Handle)) {
g_applier.DispatchProgram = kMGPipeNullHandle;
++g_applier.ProgramBindingSerial;
@@ -2668,6 +2790,12 @@ namespace MobileGL::MG_Pipe {
// ON THE PROGRAM'S RECORD, not in the working state, and that is what makes it survive a
// make-current: the block is (ShaderCso, Version) keyed and belongs to the program, not
// to the context that last uploaded it.
//
// P4a's belt goes in front of the resolution for set_texture_params' reason, and it is
// in front of the fault block too because THIS call's bounds come out of the resolved
// program's own GlobalUboSize - there is nothing to validate against until the record
// is in hand.
if (NoP4aConsumer()) return;
MGPipeShaderCsoRecord* stored = ResolveShaderCso("set_global_constants", record.ShaderCso);
if (stored == nullptr) return;
+42 -1
View File
@@ -523,6 +523,35 @@ namespace MobileGL::MG_Pipe {
// 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
// advance (see there).
@@ -755,6 +784,11 @@ namespace MobileGL::MG_Pipe {
// 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;
@@ -979,7 +1013,14 @@ namespace MobileGL::MG_Pipe {
// 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".
void MGPipeApplySetTextureParams(const MGPTextureParams& params);
//
// 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
+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) \
+7 -1
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) \
+62 -3
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,
@@ -308,12 +312,44 @@ namespace MobileGL::MG_Pipe {
//
// Entry points MGPipeTextureEmitter must provide, all taking the frontend object by
// reference and returning void:
// EmitResourceCreate(ITextureObject&) / EmitResourceRespecify(ITextureObject&)
// 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);
void MGPipeEmitTextureResourceRespecify(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
@@ -325,6 +361,29 @@ namespace MobileGL::MG_Pipe {
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:
+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
@@ -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
}
};
+424 -4
View File
@@ -50,8 +50,21 @@
#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>
@@ -72,13 +85,24 @@ namespace {
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
// `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
@@ -104,8 +128,14 @@ namespace {
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
// 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;
@@ -116,7 +146,7 @@ namespace {
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
result.Log = ReadLog(before);
return result;
}
@@ -347,6 +377,393 @@ TEST(CompositeResolver, ASlotAtTheShaderCsoLimitIsRefusedWhileTheLastBandSlotIsN
#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 =
@@ -358,6 +775,9 @@ int main(int argc, char** argv) {
_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();
@@ -57,8 +57,23 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
// 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/FramebufferEmit.h>
#include <MG_Impl/Pipe/SetHashSuppressor.h>
#include <MG_Pipe/PipeApply.h>
#include <MG_State/GLState/Core.h>
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
#include <MG_State/GLState/RenderbufferState/RenderbufferObject.h>
#include <MG_State/GLState/TextureState/TextureObject2D.h>
#include <MG_State/GLState/TextureState/TextureObject2DCube.h>
#include <algorithm>
#endif
using namespace MobileGL;
@@ -202,6 +217,592 @@ TEST(FramebufferEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#endif
}
// ============================================================================
// P4a package B's EMITTER-SIDE cases. The contract commit landed the file, its ctest
// registration and one shape pin; the wire package's applier-side cases and these are disjoint
// TEST bodies in one file, and a collision between them is resolved by UNION, never by
// choosing a side.
//
// EVERY CASE FAILS BY FIELD NAME, never by a bare count: G7's scripted control stops the
// conversion copying exactly one member (MGPSurface::Layered) and expects this suite to go red
// NAMING that field, and a case that reported only "the records differ" could not answer it.
// ============================================================================
#if !MOBILEGL_PIPE_PUSH
#define MGL_FRAMEBUFFER_EMIT_CLIENT_TEST_LIST(X) \
X(FramebufferEmit, EveryRecordsReadSurfaceComesFromItsOwnFramebuffersReadBuffer) \
X(FramebufferEmit, OneObjectBoundToBothTargetsEmitsOneRecordWithTargetBoth) \
X(FramebufferEmit, ADrawBufferChangeAloneStillMovesTheContentHash) \
X(FramebufferEmit, ARecycledFramebufferHandleIsNeverSuppressedAgainstItsPredecessor) \
X(FramebufferEmit, EveryAttachmentFieldSurvivesTheSurfaceConversion) \
X(FramebufferEmit, AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated) \
X(FramebufferEmit, ARestoragedAttachedRenderbufferPublishesItsNewExtent) \
X(FramebufferEmit, AnUnchangedBindingPairEmitsNothing) \
X(FramebufferEmit, AFramebufferHandedOverByNameGetsANamedRecordWithoutMovingABinding) \
X(FramebufferEmit, ANamedRecordIsSuppressedPerObjectAndNeverAgainstABoundRecord) \
X(FramebufferEmit, ADrawBufferTokenAboveTheWireWidthIsRefusedNotTruncated) \
X(FramebufferEmit, ALayeredCubeAttachmentDoesNotAssertAFaceItCannotKnow) \
X(FramebufferEmit, EveryNonTexturePointCarriesTheUnknownSentinelsRatherThanZero) \
X(FramebufferEmit, ADeadFramebuffersNamedRecordLatchIsRetired)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_FRAMEBUFFER_EMIT_CLIENT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::FramebufferObject;
using MG_State::GLState::MipmapInput;
using MG_State::GLState::RenderbufferObject;
using MG_State::GLState::TextureObject2D;
using MG_State::GLState::TextureObject2DCube;
// AN RAII SCOPE RATHER THAN A gtest FIXTURE, for VertexInputEmitTest's reason, and it arms
// BOTH bits this family needs: the framebuffer bit for the emission itself, and the
// texture-resource bit because MGPSurface::Res names a texture or renderbuffer handle and
// bit 9 requires bit 10 for exactly that reason.
struct FramebufferScope {
FramebufferScope() {
m_previousPush = MG_Config::Features.PipePush;
// D-K2, ALL THREE ROWS THAT REACH THIS SUITE, because the client enforces them since
// S-3 / ID-41 and not only Espryt's Resolve*SubsystemArm: bit 9 requires bit 10
// (MGPSurface::Res names a texture or renderbuffer handle), bit 10 requires bit 11
// (MGPTextureParams::BuiltinSampler is a SamplerCso out of the sampler family's
// content-addressed cache) and bit 10 requires bit 7 (a buffer texture's
// BufferForTexBuffer names a Buffer handle, D-D1). Bit 7 changes nothing else here:
// this file constructs no BufferObject.
MG_Config::Features.PipePush |= kMGPipeSubsystemResources |
kMGPipeSubsystemFramebuffer |
kMGPipeSubsystemTextureResources |
kMGPipeSubsystemSamplers;
m_previousContext = Move(MG_State::pGLContext);
MG_State::pGLContext = MakeUnique<GLContext>();
MGPipeFramebufferEmitterInstance().ResetForTest();
MGPipeTextureEmitterInstance().ResetForTest();
MGPipeSetHashSuppressorInstance().InvalidateAll();
// The applier is a process singleton and its object records outlive a case; with both
// families' wired constants set the emissions actually land, so a case must start
// from an empty table (v1 armed the texture emitter here instead - ArmForTest is
// gone with the flip).
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
}
~FramebufferScope() {
MGPipeFramebufferEmitterInstance().ResetForTest();
MGPipeTextureEmitterInstance().ResetForTest();
MGPipeSetHashSuppressorInstance().InvalidateAll();
MGPipeApplierReset();
MGPipeApplierReleaseObjectRecords();
MG_State::pGLContext.reset();
MG_State::pGLContext = Move(m_previousContext);
MG_Config::Features.PipePush = m_previousPush;
}
FramebufferScope(const FramebufferScope&) = delete;
FramebufferScope& operator=(const FramebufferScope&) = delete;
UniquePtr<GLContext> m_previousContext;
Uint64 m_previousPush = 0;
};
MGPipeFramebufferEmitter& Framebuffers() { return MGPipeFramebufferEmitterInstance(); }
GLContext& Ctx() { return *MG_State::pGLContext; }
SharedPtr<TextureObject2D> MakeColorTexture(Uint name, Int size, Uint levels = 1) {
auto texture = MakeShared<TextureObject2D>(name);
texture->SetInternalFormat(TextureInternalFormat::RGBA8);
for (Uint level = 0; level < levels; ++level) {
const Int extent = std::max<Int>(size >> level, 1);
texture->AllocateStorage(TextureUploadTarget::Texture2D, level,
MipmapInput{IntVec3{extent, extent, 1},
static_cast<SizeT>(extent) * static_cast<SizeT>(extent) * 4});
}
return texture;
}
void BindDrawAndRead(const SharedPtr<FramebufferObject>& draw, const SharedPtr<FramebufferObject>& read) {
Ctx().GetFramebufferBindingSlot(FramebufferTarget::Draw).Bind(draw);
Ctx().GetFramebufferBindingSlot(FramebufferTarget::Read).Bind(read);
}
} // namespace
// ============================ D-C1 / D-C2 ============================
//
// THE RESOLVED READ SURFACE IS WHAT STRUCTURALLY CLOSES THE read-buffer-shared-FBO DEFECT
// CLASS. The record carries the surface itself rather than an index, and EVERY record resolves
// it from the framebuffer ITS OWN Fbo names - 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: no field of this record may refer to "whatever is bound", and a glReadBuffer on
// the read FBO moved the draw record's ContentHash and forced a redundant draw emission.
TEST(FramebufferEmit, EveryRecordsReadSurfaceComesFromItsOwnFramebuffersReadBuffer) {
FramebufferScope scope;
const auto drawColor = MakeColorTexture(1, 32);
const auto readColor0 = MakeColorTexture(2, 32);
const auto readColor1 = MakeColorTexture(3, 32);
const auto drawFbo = MakeShared<FramebufferObject>(1);
drawFbo->AttachTexture(FramebufferAttachmentType::Color0, drawColor, TextureUploadTarget::Texture2D);
const auto readFbo = MakeShared<FramebufferObject>(2);
readFbo->AttachTexture(FramebufferAttachmentType::Color0, readColor0, TextureUploadTarget::Texture2D);
readFbo->AttachTexture(FramebufferAttachmentType::Color1, readColor1, TextureUploadTarget::Texture2D);
readFbo->SetReadBuffer(FramebufferAttachmentType::Color1);
BindDrawAndRead(drawFbo, readFbo);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 2u) << "two distinct bindings are two records";
const MGPipeHandle readColor1Handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, readColor1->GetLifetimeId());
ASSERT_FALSE(MGPipeHandleIsNull(readColor1Handle));
const MGPipeHandle drawColorHandle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, drawColor->GetLifetimeId());
ASSERT_FALSE(MGPipeHandleIsNull(drawColorHandle));
EXPECT_EQ(Framebuffers().LastDraw().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Draw));
EXPECT_TRUE(Framebuffers().LastDraw().ReadSurface.Res == drawColorHandle)
<< "the DRAW record's ReadSurface names a surface that is not part of the framebuffer its "
"own Fbo names";
EXPECT_EQ(Framebuffers().LastRead().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Read));
EXPECT_TRUE(Framebuffers().LastRead().ReadSurface.Res == readColor1Handle)
<< "MGPFramebufferState::ReadSurface on the READ record did not come from that "
"framebuffer's own read buffer";
// AND THE DRAW RECORD DOES NOT MOVE WHEN THE READ FRAMEBUFFER'S READ BUFFER DOES.
const Uint64 drawHashBefore = Framebuffers().LastDraw().ContentHash;
const Uint64 emissionsBefore = Framebuffers().EmissionCount();
readFbo->SetReadBuffer(FramebufferAttachmentType::Color0);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), emissionsBefore + 1)
<< "only the READ record moved, so exactly one record goes out";
EXPECT_EQ(Framebuffers().LastDraw().ContentHash, drawHashBefore)
<< "a glReadBuffer on the read framebuffer moved the DRAW record's content hash";
// And the draw-buffer array belongs to the DRAW object, whichever record carries it.
EXPECT_EQ(Framebuffers().LastDraw().DrawBuffers[0], 0);
}
TEST(FramebufferEmit, OneObjectBoundToBothTargetsEmitsOneRecordWithTargetBoth) {
FramebufferScope scope;
const auto color0 = MakeColorTexture(4, 32);
const auto color1 = MakeColorTexture(5, 32);
const auto fbo = MakeShared<FramebufferObject>(3);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color0, TextureUploadTarget::Texture2D);
fbo->AttachTexture(FramebufferAttachmentType::Color1, color1, TextureUploadTarget::Texture2D);
fbo->SetReadBuffer(FramebufferAttachmentType::Color1);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 1u) << "one object on both targets is ONE record";
EXPECT_EQ(Framebuffers().LastDraw().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Both));
const MGPipeHandle color1Handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, color1->GetLifetimeId());
EXPECT_TRUE(Framebuffers().LastDraw().ReadSurface.Res == color1Handle)
<< "the shared-FBO record's ReadSurface is not that object's own read buffer";
}
// ============================ D-C4 ============================
//
// THE fragColor BROADCAST TRAP. The backend derives the broadcast count from the draw-buffer
// array, 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.
TEST(FramebufferEmit, ADrawBufferChangeAloneStillMovesTheContentHash) {
FramebufferScope scope;
const auto color0 = MakeColorTexture(6, 32);
const auto color1 = MakeColorTexture(7, 32);
const auto fbo = MakeShared<FramebufferObject>(4);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color0, TextureUploadTarget::Texture2D);
fbo->AttachTexture(FramebufferAttachmentType::Color1, color1, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
const Uint64 before = Framebuffers().LastDraw().ContentHash;
const Int8 slot1Before = Framebuffers().LastDraw().DrawBuffers[1];
// NOTHING BUT THE DRAW-BUFFER ARRAY MOVES: the same attachments, the same extent, the same
// completeness answer, the same handle.
fbo->SetDrawBuffer(1, FramebufferAttachmentType::Color1);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 2u)
<< "a draw-buffer change alone was suppressed, which would freeze the fragColor "
"broadcast count at the previous output shape";
EXPECT_NE(Framebuffers().LastDraw().ContentHash, before)
<< "MGPFramebufferState::ContentHash does not cover DrawBuffers[]";
EXPECT_NE(Framebuffers().LastDraw().DrawBuffers[1], slot1Before);
EXPECT_EQ(Framebuffers().LastDraw().DrawBuffers[1], 1);
}
TEST(FramebufferEmit, ARecycledFramebufferHandleIsNeverSuppressedAgainstItsPredecessor) {
FramebufferScope scope;
// The SAME attachment set on both objects, so every other field of the record is identical
// and Fbo is the only thing that can move the hash.
const auto color = MakeColorTexture(8, 32);
Uint64 firstHash = 0;
MGPipeHandle firstHandle{};
{
const auto first = MakeShared<FramebufferObject>(5);
first->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(first, first);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
firstHash = Framebuffers().LastDraw().ContentHash;
firstHandle = Framebuffers().LastDraw().Fbo;
BindDrawAndRead(nullptr, nullptr);
}
const auto second = MakeShared<FramebufferObject>(5);
second->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(second, second);
Framebuffers().EmitFramebufferState(Ctx());
const MGPFramebufferState& record = Framebuffers().LastDraw();
EXPECT_EQ(record.Fbo.Slot, firstHandle.Slot) << "the slot was not recycled; the case proves nothing";
EXPECT_NE(record.Fbo.Gen, firstHandle.Gen) << "a recycled slot must carry a new generation";
EXPECT_NE(record.ContentHash, firstHash)
<< "MGPFramebufferState::ContentHash does not cover Fbo, so a recycled framebuffer handle "
"would be suppressed against its predecessor's record";
}
// ============================ G6 ============================
//
// "For every framebuffer configuration the emitted MGPFramebufferState reproduces exactly the
// values the backend's SyncToBackend family reads from the frontend today, field by field."
// The oracle is the frontend attachment itself, read back through the same getters the twin
// uses, so this cannot drift into asserting what the emitter happens to do.
TEST(FramebufferEmit, EveryAttachmentFieldSurvivesTheSurfaceConversion) {
FramebufferScope scope;
const auto fbo = MakeShared<FramebufferObject>(6);
Vector<SharedPtr<TextureObject2D>> colors;
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
colors.push_back(MakeColorTexture(static_cast<Uint>(20 + i), 32, 2));
// Distinct level, layer and layered flag per point, so a conversion that dropped one
// field could not be masked by another point's value.
fbo->AttachTexture(static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) + static_cast<Int>(i)),
colors.back(), TextureUploadTarget::Texture2D,
static_cast<int>(i % 2), static_cast<int>(i), (i % 2) == 0);
}
const auto depth = MakeShared<RenderbufferObject>(2);
depth->SetInternalFormat(TextureInternalFormat::Depth24Stencil8);
depth->AllocateStorage(IntVec2{32, 32});
fbo->AttachRenderbuffer(FramebufferAttachmentType::Depth, depth);
const auto stencil = MakeColorTexture(40, 32);
fbo->AttachTexture(FramebufferAttachmentType::Stencil, stencil, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
const MGPFramebufferState& record = Framebuffers().LastDraw();
for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) {
const auto type = static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) + static_cast<Int>(i));
const auto& attachment = fbo->GetAttachment(type);
const MGPSurface& surface = record.Color[i];
const MGPipeHandle expected =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, attachment.GetTexture()->GetLifetimeId());
EXPECT_TRUE(surface.Res == expected) << "MGPSurface::Res at colour point " << i;
EXPECT_EQ(surface.Kind, kMGPipeSurfaceKindTexture) << "MGPSurface::Kind at colour point " << i;
EXPECT_EQ(surface.InternalFormat, static_cast<Uint32>(attachment.GetTexture()->GetFormat()))
<< "MGPSurface::InternalFormat at colour point " << i;
EXPECT_EQ(surface.Level, static_cast<Uint16>(attachment.GetTextureLevel()))
<< "MGPSurface::Level at colour point " << i;
EXPECT_EQ(surface.Layer, static_cast<Uint32>(attachment.GetTextureLayer()))
<< "MGPSurface::Layer at colour point " << i;
EXPECT_EQ(surface.Layered, attachment.IsLayered() ? 1 : 0)
<< "MGPSurface::Layered at colour point " << i;
EXPECT_EQ(surface.UploadTarget, static_cast<Uint16>(TextureUploadTarget::Texture2D))
<< "MGPSurface::UploadTarget at colour point " << i;
// ID-12 DV-5: three of the four cross-object masks reduce to (format, TEXTURE TARGET)
// and no TextureUploadTarget -> TextureTarget inverse exists anywhere in the tree.
EXPECT_EQ(surface.TextureTarget,
static_cast<Uint16>(attachment.GetTexture()->GetTarget()))
<< "MGPSurface::TextureTarget at colour point " << i;
}
EXPECT_EQ(record.Depth.Kind, kMGPipeSurfaceKindRenderbuffer) << "MGPSurface::Kind on the depth point";
EXPECT_EQ(record.Depth.InternalFormat, static_cast<Uint32>(depth->GetInternalFormat()))
<< "MGPSurface::InternalFormat on the depth point";
EXPECT_TRUE(record.Depth.Res ==
MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, depth->GetLifetimeId()))
<< "MGPSurface::Res on the depth point";
EXPECT_EQ(record.Depth.Layered, 0) << "MGPSurface::Layered on the depth point";
EXPECT_EQ(record.Depth.TextureTarget, kMGPipeSurfaceNoTextureTarget)
<< "MGPSurface::TextureTarget on the RENDERBUFFER point must be the Unknown sentinel";
EXPECT_EQ(record.Stencil.Kind, kMGPipeSurfaceKindTexture) << "MGPSurface::Kind on the stencil point";
EXPECT_TRUE(record.Stencil.Res ==
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, stencil->GetLifetimeId()))
<< "MGPSurface::Res on the stencil point";
EXPECT_EQ(record.Width, 32u) << "MGPFramebufferState::Width";
EXPECT_EQ(record.Height, 32u) << "MGPFramebufferState::Height";
EXPECT_EQ(record.IsDefault, 0) << "MGPFramebufferState::IsDefault";
// Complete is FramebufferObject::CheckCompleteness(), the FRONTEND-only answer, and never
// glCheckFramebufferStatus's - that entry point additionally consults the backend's probed
// format-capability cache, which a client emitting it would be reading from the wrong side.
EXPECT_EQ(record.Complete, fbo->CheckCompleteness() ? 1 : 0) << "MGPFramebufferState::Complete";
// And an empty point really is {null, None} and every other field zero.
const auto emptyFbo = MakeShared<FramebufferObject>(7);
emptyFbo->AttachTexture(FramebufferAttachmentType::Color0, colors[0], TextureUploadTarget::Texture2D);
BindDrawAndRead(emptyFbo, emptyFbo);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_TRUE(MGPipeHandleIsNull(Framebuffers().LastDraw().Color[1].Res));
EXPECT_EQ(Framebuffers().LastDraw().Color[1].Kind, kMGPipeSurfaceKindNone);
EXPECT_EQ(Framebuffers().LastDraw().Color[1].InternalFormat, 0u);
// BOTH TARGET FIELDS CARRY THE SENTINEL, not 0 (m6 / ID-12 DV-5): a Uint16 zero is
// TextureUploadTarget::Texture1D and TextureTarget::Texture1D, so a reader that forgot to
// gate on Kind would read a plausible wrong answer instead of a nonsense one.
EXPECT_EQ(Framebuffers().LastDraw().Color[1].UploadTarget,
static_cast<Uint16>(TextureUploadTarget::Unknown));
EXPECT_EQ(Framebuffers().LastDraw().Color[1].TextureTarget, kMGPipeSurfaceNoTextureTarget);
}
// ============================ D-C3 ============================
TEST(FramebufferEmit, AnAttachmentPointAboveTheWireWidthIsRefusedNotTruncated) {
FramebufferScope scope;
const auto color = MakeColorTexture(50, 32);
const auto beyond = MakeColorTexture(51, 32);
const auto fbo = MakeShared<FramebufferObject>(8);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
ASSERT_EQ(Framebuffers().RefusedCount(), 0u);
// MGPFramebufferState::Color[] is eight wide and MaxColorAttachments is the driver's raw ES
// cap, not clamped to eight on the GLES path. Truncating silently is the bug class this
// phase is closing, so the record is REFUSED and the legacy arm runs.
fbo->AttachTexture(static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) +
static_cast<Int>(kMGPipeMaxColorAttachments)),
beyond, TextureUploadTarget::Texture2D);
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 1u)
<< "an attachment above the wire width was truncated into a record instead of refused";
EXPECT_GE(Framebuffers().RefusedCount(), 1u) << "the refusal was not counted";
}
// ============================ D-D2 ============================
//
// RED BEFORE THIS PACKAGE LANDED. RenderbufferObject's three storage setters bump no version
// and raise no notice, and the framebuffer bit's shutter does not move when an ALREADY-ATTACHED
// renderbuffer is re-storaged - so `glBindRenderbuffer; glRenderbufferStorage(newSize)` on an
// attached renderbuffer published nothing at all.
TEST(FramebufferEmit, ARestoragedAttachedRenderbufferPublishesItsNewExtent) {
FramebufferScope scope;
const auto renderbuffer = MakeShared<RenderbufferObject>(3);
renderbuffer->SetInternalFormat(TextureInternalFormat::Depth24Stencil8);
renderbuffer->AllocateStorage(IntVec2{64, 64});
const auto fbo = MakeShared<FramebufferObject>(9);
fbo->AttachRenderbuffer(FramebufferAttachmentType::Depth, renderbuffer);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
const MGPipeHandle handle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, renderbuffer->GetLifetimeId());
ASSERT_FALSE(MGPipeHandleIsNull(handle));
const Uint64 respecifiesBefore = MGPipeTextureEmitterInstance().RespecifyCount();
// NO BIND, NO ATTACHMENT CHANGE, NO DIRTY BIT - only the storage entry point.
renderbuffer->AllocateStorage(IntVec2{128, 96});
EXPECT_GT(MGPipeTextureEmitterInstance().RespecifyCount(), respecifiesBefore)
<< "a re-storaged attached renderbuffer published nothing";
const MGPResourceDesc desc = MGPipeTextureEmitterInstance().LastDesc();
EXPECT_TRUE(desc.Resource == handle);
EXPECT_EQ(desc.Width, 128u);
EXPECT_EQ(desc.Height, 96u);
}
TEST(FramebufferEmit, AnUnchangedBindingPairEmitsNothing) {
FramebufferScope scope;
const auto color = MakeColorTexture(60, 32);
const auto fbo = MakeShared<FramebufferObject>(10);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
const Uint64 bytes = Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(bytes, sizeof(MGPFramebufferState));
EXPECT_EQ(Framebuffers().EmissionCount(), 1u);
// The version-first skip: nothing moved, so nothing goes out and nothing is hashed twice.
EXPECT_EQ(Framebuffers().EmitFramebufferState(Ctx()), 0u);
EXPECT_EQ(Framebuffers().EmissionCount(), 1u);
}
// ============================ ID-19(c) ============================
//
// THE HOLE esprytobj's C-1 FOUND. The applier keeps framebuffer records PER OBJECT, but v1 only
// ever built the two BOUND-target records at the validate point - so glClearNamedFramebufferfv
// on an fbo bound to NEITHER binding reached a backend that minted a driver framebuffer with no
// attachments, found no record for it, declined, and cleared against it anyway.
TEST(FramebufferEmit, AFramebufferHandedOverByNameGetsANamedRecordWithoutMovingABinding) {
FramebufferScope scope;
const auto boundColor = MakeColorTexture(70, 32);
const auto namedColor = MakeColorTexture(71, 16);
const auto bound = MakeShared<FramebufferObject>(20);
bound->AttachTexture(FramebufferAttachmentType::Color0, boundColor, TextureUploadTarget::Texture2D);
BindDrawAndRead(bound, bound);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
const MGPipeHandle boundHandle = MGPipeApplier().DrawFramebuffer()->Fbo;
const Uint64 serialBefore = MGPipeApplier().FramebufferSerial;
// The object the DSA entry point is about to hand over, bound to neither binding.
const auto named = MakeShared<FramebufferObject>(21);
named->AttachTexture(FramebufferAttachmentType::Color0, namedColor, TextureUploadTarget::Texture2D);
const Uint64 bytes = Framebuffers().EmitFramebufferByName(*named);
EXPECT_EQ(bytes, sizeof(MGPFramebufferState));
EXPECT_EQ(Framebuffers().LastNamed().Target, static_cast<Uint8>(MGPipeFramebufferTarget::Named));
EXPECT_GT(MGPipeApplier().FramebufferSerial, serialBefore) << "a Named record must publish";
const MGPFramebufferState* stored =
MGPipeApplier().FramebufferRecordFor(Framebuffers().LastNamed().Fbo);
ASSERT_NE(stored, nullptr) << "the framebuffer the server is about to receive by name has no record";
EXPECT_EQ(stored->Width, 16u) << "the Named record does not describe the object it names";
// AND IT MOVED NEITHER BINDING.
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_TRUE(MGPipeApplier().DrawFramebuffer()->Fbo == boundHandle)
<< "a Named record took the draw binding";
ASSERT_NE(MGPipeApplier().ReadFramebuffer(), nullptr);
EXPECT_TRUE(MGPipeApplier().ReadFramebuffer()->Fbo == boundHandle)
<< "a Named record took the read binding";
}
TEST(FramebufferEmit, ANamedRecordIsSuppressedPerObjectAndNeverAgainstABoundRecord) {
FramebufferScope scope;
const auto colorA = MakeColorTexture(72, 32);
const auto colorB = MakeColorTexture(73, 32);
const auto first = MakeShared<FramebufferObject>(22);
first->AttachTexture(FramebufferAttachmentType::Color0, colorA, TextureUploadTarget::Texture2D);
const auto second = MakeShared<FramebufferObject>(23);
second->AttachTexture(FramebufferAttachmentType::Color0, colorB, TextureUploadTarget::Texture2D);
// TWO DIFFERENT OBJECTS' Named RECORDS IN A ROW MUST BOTH GO OUT: the suppressor is keyed by
// the framebuffer the record names and never by one global slot.
EXPECT_GT(Framebuffers().EmitFramebufferByName(*first), 0u);
EXPECT_GT(Framebuffers().EmitFramebufferByName(*second), 0u);
EXPECT_EQ(Framebuffers().EmissionCount(), 2u);
// A repeat of the same object with nothing moved costs nothing.
EXPECT_EQ(Framebuffers().EmitFramebufferByName(*second), 0u);
EXPECT_EQ(Framebuffers().EmissionCount(), 2u);
// ...and the first object's record is still its own, not the second's.
EXPECT_EQ(Framebuffers().EmitFramebufferByName(*first), 0u);
// A BOUND OBJECT IS NEVER HANDED A Named RECORD, because the record would then say "no
// binding" while BoundFramebuffer still resolves through it.
BindDrawAndRead(first, nullptr);
Framebuffers().EmitFramebufferByName(*first);
ASSERT_NE(MGPipeApplier().DrawFramebuffer(), nullptr);
EXPECT_EQ(MGPipeApplier().DrawFramebuffer()->Target, static_cast<Uint8>(MGPipeFramebufferTarget::Draw))
<< "a by-name publish of a DRAW-bound framebuffer overwrote its record with Target = Named";
}
// ============================ m2 ============================
TEST(FramebufferEmit, ADrawBufferTokenAboveTheWireWidthIsRefusedNotTruncated) {
FramebufferScope scope;
const auto color = MakeColorTexture(74, 32);
const auto fbo = MakeShared<FramebufferObject>(24);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
ASSERT_EQ(Framebuffers().RefusedCount(), 0u);
// A LEGAL STATE THE ATTACHMENT SCAN CANNOT SEE: nothing is attached at the point the token
// names, so D-C3's attachment loop passes, and MGPipeDrawBufferIndex would write an index of
// 8 or more into an 8-wide array.
fbo->SetDrawBuffer(0, static_cast<FramebufferAttachmentType>(
static_cast<Int>(FramebufferAttachmentType::Color0) +
static_cast<Int>(kMGPipeMaxColorAttachments) + 2));
Framebuffers().EmitFramebufferState(Ctx());
EXPECT_EQ(Framebuffers().EmissionCount(), 1u)
<< "a draw-buffer token naming a colour point at or above the wire width was truncated "
"into a record instead of refused";
EXPECT_GE(Framebuffers().RefusedCount(), 1u) << "the refusal was not counted";
}
// ============================ m1 ============================
TEST(FramebufferEmit, ALayeredCubeAttachmentDoesNotAssertAFaceItCannotKnow) {
FramebufferScope scope;
const auto cube = MakeShared<TextureObject2DCube>(75);
cube->SetInternalFormat(TextureInternalFormat::RGBA8);
for (const TextureUploadTarget face : cube->GetUploadTargets()) {
cube->AllocateStorage(face, 0, MipmapInput{IntVec3{16, 16, 1}, 16 * 16 * 4});
}
ASSERT_GT(cube->GetUploadTargets().size(), 1u);
const auto fbo = MakeShared<FramebufferObject>(25);
// The LAYERED entry point carries no face token, so the attachment stores Unknown. v1 fell
// back to GetUploadTargets()[0] - which is CubeMapPositiveX - and the record then ASSERTED a
// face that is not the truth for an attachment naming all six. The precedent it copied
// (FramebufferAttachmentObject::GetSize) only needs an EXTENT, which is identical across the
// six faces; face identity is not.
fbo->AttachTexture(FramebufferAttachmentType::Color0, cube, TextureUploadTarget::Unknown, 0, 0, true);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
ASSERT_EQ(Framebuffers().EmissionCount(), 1u);
EXPECT_EQ(Framebuffers().LastDraw().Color[0].Layered, 1);
EXPECT_EQ(Framebuffers().LastDraw().Color[0].UploadTarget,
static_cast<Uint16>(TextureUploadTarget::Unknown))
<< "a layered cube attachment resolved to one face, so the record asserts a face the "
"attachment does not name";
EXPECT_EQ(Framebuffers().LastDraw().Color[0].TextureTarget,
static_cast<Uint16>(TextureTarget::TextureCubeMap))
<< "MGPSurface::TextureTarget is what a cross-object mask reads instead";
}
TEST(FramebufferEmit, EveryNonTexturePointCarriesTheUnknownSentinelsRatherThanZero) {
FramebufferScope scope;
const auto color = MakeColorTexture(76, 32);
const auto depth = MakeShared<RenderbufferObject>(4);
depth->SetInternalFormat(TextureInternalFormat::Depth24Stencil8);
depth->AllocateStorage(IntVec2{32, 32});
const auto fbo = MakeShared<FramebufferObject>(26);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
fbo->AttachRenderbuffer(FramebufferAttachmentType::Depth, depth);
BindDrawAndRead(fbo, fbo);
Framebuffers().EmitFramebufferState(Ctx());
const MGPFramebufferState& record = Framebuffers().LastDraw();
// The RENDERBUFFER point, the EMPTY colour points and the STENCIL point that names nothing.
for (const MGPSurface* surface : {&record.Depth, &record.Stencil, &record.Color[1]}) {
EXPECT_NE(surface->Kind, kMGPipeSurfaceKindTexture);
EXPECT_EQ(surface->UploadTarget, static_cast<Uint16>(TextureUploadTarget::Unknown))
<< "a non-texture point says TextureUploadTarget::Texture1D, which is what 0 means";
EXPECT_EQ(surface->TextureTarget, kMGPipeSurfaceNoTextureTarget)
<< "a non-texture point says TextureTarget::Texture1D, which is what 0 means";
}
// And the texture point is unaffected.
EXPECT_EQ(record.Color[0].TextureTarget, static_cast<Uint16>(TextureTarget::Texture2D));
// THE FIELD IS IN THE HASH, so a texture-target change alone cannot be suppressed.
MGPSurface probe = record.Color[0];
MGPFramebufferState probeState = record;
probeState.Color[0].TextureTarget = static_cast<Uint16>(TextureTarget::TextureRectangle);
EXPECT_NE(MGPipeFramebufferStateContentHash(probeState), MGPipeFramebufferStateContentHash(record))
<< "MGPipeCopySurfaceForHash does not copy MGPSurface::TextureTarget, so a record whose "
"only moved field is the attachment's texture target would be suppressed";
(void)probe;
}
// ============================ final review C-2 ============================
//
// A framebuffer has no wire lifetime (D-I2), so the only client state under its handle is this
// emitter's per-object Named latch - and the death helper retires it before the slot is freed,
// the shape every P4a kind takes (ID-8). A recycled handle's Gen already refused the stale
// latch, so this pins the hygiene rather than a picture.
TEST(FramebufferEmit, ADeadFramebuffersNamedRecordLatchIsRetired) {
FramebufferScope scope;
MGPipeHandle handle{};
{
const auto fbo = MakeShared<FramebufferObject>(31);
const auto color = MakeColorTexture(32, 8);
fbo->AttachTexture(FramebufferAttachmentType::Color0, color, TextureUploadTarget::Texture2D);
handle = MGPipeFramebufferEmitter::HandleFor(*fbo);
ASSERT_GT(Framebuffers().EmitFramebufferByName(*fbo), 0u) << "the Named record did not go out";
ASSERT_TRUE(Framebuffers().NamedRecordIsLatched(handle));
}
EXPECT_FALSE(MGPipeSlots().IsLive(MGPipeKind::Framebuffer, handle));
EXPECT_FALSE(Framebuffers().NamedRecordIsLatched(handle))
<< "the dead framebuffer's Named latch survived its death";
}
#endif // MOBILEGL_PIPE_PUSH
// =========================================================================================
// The APPLIER's half of set_framebuffer_state (the wire commits'). The emitter's half - the
// resolved read surface, the draw-buffer array in the content hash, a recycled handle never
@@ -575,6 +1176,20 @@ int main(int argc, char** argv) {
_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
// ID-39: A BACKEND IS PRESENT, for the whole binary. Since ID-39 every P4a-family entry
// point in MG_Pipe/PipeApply.cpp declines a record - and the client's gate in
// MG_Impl/Pipe/PipeFill.cpp emits none at all - when no backend has registered
// MGPipeResourceOps, because acceptance is a contract with the emitter and an accepted
// record nothing reads makes the client clear work the legacy pull path still owed. Every
// case in this suite is about the arm where a backend DOES consume the records, which is
// the shipped DirectGLES configuration, so it installs the same signal that backend
// installs. The table is empty because none of its hooks is on a framebuffer path at all:
// set_framebuffer_state stores a record and dispatches nothing. The two arms of the rule
// itself are pinned in ResourceEmitTest and TextureEmitTest.
static const MGPipeResourceOps kConsumerPresent{};
MGPipeSetResourceOps(&kConsumerPresent);
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
+265 -20
View File
@@ -6,20 +6,16 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P4a's image-unit set: set_shader_images, the third of the three kVarTail unit sets. It rides
// the sampler family's subsystem bit - one family, one A/B - and has its own suite because its
// content hash has to cover two fields the other two sets do not carry.
// P4a's third kVarTail unit set, set_shader_images. It rides the sampler family's subsystem
// bit: one family, one A/B.
//
// THE TWO CASES THIS SUITE EXISTS FOR: an ACCESS-mode change alone, and an INTERNAL-FORMAT
// change alone, each has to move the hash and emit the set. Both are live glBindImageTexture
// state, the format-less image bake keys on the format the shader was built against, and a
// hash over the bindings alone would suppress exactly the record that says the bake is stale.
// The behavioural gates beside them are the format-less bake and non-core-format scenarios,
// and the photon fixture on desktop retrace - the only fixture that has ever caught an
// image-binding-semantics regression, and one that must never be run on the Adreno.
// 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,
// this directory's convention, and it is what the gates grep for.
// 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.
//
@@ -48,8 +44,22 @@
#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;
@@ -67,13 +77,24 @@ namespace {
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
// `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
@@ -99,8 +120,14 @@ namespace {
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
// 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;
@@ -111,7 +138,7 @@ namespace {
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
result.Log = ReadLog(before);
return result;
}
@@ -157,10 +184,9 @@ namespace {
TEST(ImageEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
#if MOBILEGL_PIPE_PUSH
EXPECT_EQ(&MGPipeImageEmitterInstance(), &MGPipeImageEmitterInstance());
// The image set's window is bounded by the same merged unit space the other two sets use;
// there is no separate image-unit capacity and there must not be one, because a record
// whose window is checked against a different bound from the array it indexes is the shape
// the applier's Fatal{ProtocolCorruption} exists to make impossible.
// 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";
@@ -323,6 +349,222 @@ TEST(ImageEmit, AMakeCurrentClearsTheImageSetAndAdvancesItsSerial) {
#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 =
@@ -334,6 +576,9 @@ int main(int argc, char** argv) {
_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();
+355 -18
View File
@@ -6,20 +6,23 @@
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// P4a's program family: create/bind/delete_shader_state, set_draw_program,
// P4a's program family on the client: create/bind/delete_shader_state, set_draw_program,
// set_dispatch_program and set_global_constants.
//
// THE ONE PIN THAT IS EASIEST TO LOSE AND WORST TO LOSE: set_global_constants' Version is
// GetUBOContentVersion(), and ~0u is the BACKENDS' "never uploaded" sentinel - the wrap skips
// it - so the client must never emit it. A record carrying the sentinel would tell a backend
// that a block it has just been handed was never uploaded.
// 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,
// this directory's convention, and it is what the gates grep for.
// 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: the
// applier-side cases are the wire commits' and the emitter-side cases are the client
// package's, and neither has to come back to MG_Test/Pipe/CMakeLists.txt to add one.
// 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
@@ -46,12 +49,24 @@
#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;
@@ -69,13 +84,24 @@ namespace {
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
// `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
@@ -101,8 +127,14 @@ namespace {
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
// 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;
@@ -113,7 +145,7 @@ namespace {
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
result.Log = ReadLog(before);
return result;
}
@@ -161,10 +193,6 @@ TEST(ProgramEmit, TheEmitterIsOneNeverDestroyedProcessSingleton) {
EXPECT_EQ(&MGPipeProgramEmitterInstance(), &MGPipeProgramEmitterInstance());
EXPECT_TRUE(kMGPipeWiredProgramSubsystem == 0 ||
kMGPipeWiredProgramSubsystem == kMGPipeSubsystemPrograms);
// The record the applier starts from carries the sentinel, not 0: a program that has never
// published a default-uniform-block image must not look like one that published version 0.
const MGPipeShaderCsoRecord fresh{};
EXPECT_EQ(fresh.GlobalConstantsVersion, ~Uint32{0});
#else
GTEST_SKIP() << "MOBILEGL_PIPE_PUSH is off: there is no client emitter in a pull build";
#endif
@@ -471,6 +499,312 @@ TEST(ProgramEmit, TheProgramRecordSurvivesAMakeCurrentWhileTheThreeBindingsDoNot
#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 =
@@ -482,6 +816,9 @@ int main(int argc, char** argv) {
_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();
+226 -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()";
@@ -1355,6 +1388,9 @@ namespace {
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));
@@ -1393,6 +1429,195 @@ namespace {
#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.
+623 -4
View File
@@ -53,8 +53,24 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <cstring>
#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/TextureEmit.h>
#include <MG_Impl/Pipe/SamplerEmit.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;
@@ -72,13 +88,24 @@ namespace {
}
#if MOBILEGL_PIPE_PUSH
std::string ReadLog() {
// `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
@@ -104,8 +131,14 @@ namespace {
template <class Body>
ChildResult RunInChild(Body body) {
ChildResult result;
std::error_code ec;
std::filesystem::remove(g_logPath, ec);
// 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;
@@ -116,7 +149,7 @@ namespace {
int status = 0;
if (::waitpid(pid, &status, 0) != pid) return result;
result.Status = status;
result.Log = ReadLog();
result.Log = ReadLog(before);
return result;
}
@@ -524,6 +557,587 @@ TEST(SamplerEmit, AMakeCurrentTakesTheUnitSetsAndLeavesTheCsoAndViewRecordsStand
#endif
}
#if !MOBILEGL_PIPE_PUSH
// G2 requires the pull and push ctest name sets to be identical, name for name, so every
// push-only case is present here and SKIPS rather than being absent.
#define MGL_SAMPLER_EMIT_TEST_LIST(X) \
X(SamplerEmit, EverySamplerParameterFieldSurvivesTheBlobConversion) \
X(SamplerEmit, PaddingCannotChangeTheHash) \
X(SamplerEmit, TwoIdenticalSamplersShareOneCso) \
X(SamplerEmit, ABorderColorFormChangeAloneMintsANewCso) \
X(SamplerEmit, AHashCollisionDoesNotAliasTwoSamplerStates) \
X(SamplerEmit, AViewIsReIssuedOnTheSameHandleWhenItsRestrictionsMove) \
X(SamplerEmit, AnUnchangedTextureReIssuesNothing) \
X(SamplerEmit, OnlyTheProgramResolvedUnitsAreEmitted) \
X(SamplerEmit, AnUnchangedSetEmitsNothing) \
X(SamplerEmit, ARedundantRebindOfTheSameSamplerEmitsNothing) \
X(SamplerEmit, ABoundSamplerStateHoldsItsCsoUntilTheUnitMoves) \
X(SamplerEmit, AReferencedCsoIsNeverTheLruVictim) \
X(SamplerEmit, AFullyPinnedCacheMintsBeyondItsCapacityAndCountsIt) \
X(SamplerEmit, AReleaseThisCacheNeverHandedOutIsCountedRatherThanAbsorbed) \
X(SamplerEmit, AResolvedSamplerViewMarksItsTextureAsSamplerBound)
#define MGL_DECLARE_PULL_SKIP(Suite, Name) \
TEST(Suite, Name) { GTEST_SKIP() << "compiled only under MOBILEGL_PIPE_PUSH"; }
MGL_SAMPLER_EMIT_TEST_LIST(MGL_DECLARE_PULL_SKIP)
#undef MGL_DECLARE_PULL_SKIP
#else
namespace {
using GLContext = MG_State::GLState::GLContext;
using MG_State::GLState::ITextureObject;
using MG_State::GLState::SamplerObject;
// AN RAII SCOPE RATHER THAN A gtest FIXTURE, for VertexInputEmitTest's reason: both gates
// grep `ctest -R 'SamplerEmit\.'`, a TEST_F files its cases under the FIXTURE's name, and
// gtest refuses to mix TEST and TEST_F under one suite name.
//
// UNLIKE VertexInputEmitTest's, this scope does NOT replace pGLContext: half these cases
// need a really linked program, so the process is initialised once in main() and the cases
// share that context, each using GL names of its own. What the scope does is put the
// emitter, its counters and the suppressor back to a known state.
struct EmitterScope {
EmitterScope() { Clear(); }
~EmitterScope() { Clear(); }
EmitterScope(const EmitterScope&) = delete;
EmitterScope& operator=(const EmitterScope&) = delete;
static void Clear() {
MGPipeSamplerEmitterInstance().Reset();
MGPipeSamplerEmitterInstance().ResetCounters();
MGPipeSamplerCsoCacheInstance().ResetForTest();
MGPipeSamplerCsoCacheInstance().ResetCounters();
MGPipeSetHashSuppressorInstance().InvalidateAll();
}
};
GLContext& Ctx() { return *MG_State::pGLContext; }
MGPipeSamplerEmitter& Emitter() { return MGPipeSamplerEmitterInstance(); }
MGPipeSamplerCsoCache& Cache() { return MGPipeSamplerCsoCacheInstance(); }
// Every field of SamplerParameters set to a value that is not its default, so a conversion
// that dropped one is caught by the field's own EXPECT rather than by a count.
SamplerParameters DistinctParameters() {
SamplerParameters params{};
params.wrapS = SamplerWrapMode::ClampToBorder;
params.wrapT = SamplerWrapMode::MirroredRepeat;
params.wrapR = SamplerWrapMode::MirrorClampToEdge;
params.minFilter = SamplerFilterMode::Linear;
params.magFilter = SamplerFilterMode::Nearest;
params.mipmapMode = SamplerMipmapMode::Nearest;
params.minLod = -3.5f;
params.maxLod = 7.25f;
params.lodBias = 1.5f;
params.maxAnisotropy = 2.0f;
params.compareFunc = SamplerCompareFunc::Greater;
params.compareMode = SamplerCompareMode::CompareToTexture;
params.borderColor = {0.25f, 0.5f, 0.75f, 1.0f};
params.borderColorI = {-1, 2, -3, 4};
params.borderColorUI = {5u, 6u, 7u, 8u};
params.borderColorForm = BorderColorForm::Int;
return params;
}
// A 2D texture with a single level and a mipmap mode of None, so it is mipmap-complete for
// its filter and therefore actually reaches the emitted set. A texture that samples as
// incomplete is dropped to a null view on purpose, which is the resolution DirectGLES
// performs by leaving the native target unbound.
const SharedPtr<ITextureObject>& MakeCompleteTexture(GLuint& name, Int width) {
namespace GL = MobileGL::MG_Impl::GLImpl;
GL::GenTextures(1, &name);
GL::BindTexture(GL_TEXTURE_2D, name);
GL::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, width, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
GL::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
return Ctx().GetTextureObject(name);
}
void BindTextureToUnit(Uint32 unit, const SharedPtr<ITextureObject>& texture) {
Ctx().GetTextureUnitObject(static_cast<Int>(unit))
.GetBindingSlot(TextureTarget::Texture2D)
.Bind(texture);
Ctx().NoteTextureUnitTouched(static_cast<Int>(unit));
}
// ============================ D-F1: the CSO cache ============================
// G6 for this family: every one of SamplerParameters' sixteen members survives the
// canonical copy the cache hashes and confirms over. Each member is its own EXPECT naming
// that member, which is what G7's scripted control needs - it stops the conversion copying
// borderColorForm and expects this case to go red NAMING it.
TEST(SamplerEmit, EverySamplerParameterFieldSurvivesTheBlobConversion) {
EmitterScope scope;
const SamplerParameters source = DistinctParameters();
SamplerParameters canon;
MGPipeCanonicaliseSamplerParameters(source, canon);
EXPECT_EQ(canon.wrapS, source.wrapS);
EXPECT_EQ(canon.wrapT, source.wrapT);
EXPECT_EQ(canon.wrapR, source.wrapR);
EXPECT_EQ(canon.minFilter, source.minFilter);
EXPECT_EQ(canon.magFilter, source.magFilter);
EXPECT_EQ(canon.mipmapMode, source.mipmapMode);
EXPECT_EQ(canon.minLod, source.minLod);
EXPECT_EQ(canon.maxLod, source.maxLod);
EXPECT_EQ(canon.lodBias, source.lodBias);
EXPECT_EQ(canon.maxAnisotropy, source.maxAnisotropy);
EXPECT_EQ(canon.compareFunc, source.compareFunc);
EXPECT_EQ(canon.compareMode, source.compareMode);
EXPECT_EQ(canon.borderColor, source.borderColor);
EXPECT_EQ(canon.borderColorI, source.borderColorI);
EXPECT_EQ(canon.borderColorUI, source.borderColorUI);
EXPECT_EQ(canon.borderColorForm, source.borderColorForm)
<< "borderColorForm decides which of glSamplerParameterIiv / fv applies, and the "
"three representations are always numerically populated, so the value alone "
"cannot say";
}
// THE PADDING TRAP. SamplerParameters is 100 bytes and its members occupy 97 of them, so
// bytes 97..99 are padding no writer ever touches. A cache that hashed the object's own
// bytes would read them, miss on every probe and mint a fresh CSO per call - a 256-entry
// cache with a hit rate of zero that nobody notices, because the pixels are right.
TEST(SamplerEmit, PaddingCannotChangeTheHash) {
EmitterScope scope;
SamplerParameters clean = DistinctParameters();
SamplerParameters dirty = clean;
auto* bytes = reinterpret_cast<Uint8*>(&dirty);
// Written THROUGH A BYTE POINTER, past the last member and inside the object, which is
// the only way to make the difference this case is about.
for (SizeT i = 97; i < sizeof(SamplerParameters); ++i) bytes[i] = static_cast<Uint8>(0xA5 + i);
SamplerParameters canonClean;
SamplerParameters canonDirty;
MGPipeCanonicaliseSamplerParameters(clean, canonClean);
MGPipeCanonicaliseSamplerParameters(dirty, canonDirty);
EXPECT_EQ(MGPipeHashSamplerParameters(canonClean), MGPipeHashSamplerParameters(canonDirty));
// And the CONFIRM, not only the hash: the cache reuses a handle on a memcmp over these
// same canonical bytes, so the padding has to be deterministically zero in both.
EXPECT_EQ(std::memcmp(&canonClean, &canonDirty, sizeof(SamplerParameters)), 0);
Uint64 payload = 0;
const MGPipeHandle first = Cache().Acquire(clean, payload);
const MGPipeHandle second = Cache().Acquire(dirty, payload);
EXPECT_EQ(first, second) << "uninitialised padding must not mint a second CSO";
EXPECT_EQ(Cache().GetCounters().Mints, 1u);
EXPECT_EQ(Cache().GetCounters().Hits, 1u);
}
TEST(SamplerEmit, TwoIdenticalSamplersShareOneCso) {
EmitterScope scope;
Uint64 payload = 0;
const SamplerParameters params = DistinctParameters();
const MGPipeHandle a = Cache().Acquire(params, payload);
const MGPipeHandle b = Cache().Acquire(params, payload);
EXPECT_FALSE(MGPipeHandleIsNull(a));
EXPECT_EQ(a, b);
EXPECT_EQ(Cache().Size(), 1u);
EXPECT_EQ(Cache().GetCounters().Mints, 1u);
// A SamplerObject is a pure value with no driver-side per-object binding state, so two
// identical samplers really can share one CSO and one server-side twin. That is what
// makes content addressing right for this kind and wrong for vertex elements.
EXPECT_GT(payload, 0u);
}
TEST(SamplerEmit, ABorderColorFormChangeAloneMintsANewCso) {
EmitterScope scope;
Uint64 payload = 0;
SamplerParameters params = DistinctParameters();
const MGPipeHandle asInt = Cache().Acquire(params, payload);
params.borderColorForm = BorderColorForm::Uint;
const MGPipeHandle asUint = Cache().Acquire(params, payload);
EXPECT_NE(asInt, asUint) << "the form is the only thing that says which driver entry "
"point applies; the three colour values did not move";
EXPECT_EQ(Cache().GetCounters().Mints, 2u);
}
// The memcmp confirm exists because a bare 64-bit equality would alias two DIFFERENT
// sampler states onto one CSO - silent wrong filtering with no gate that can see it.
//
// A GENUINE XXH64 COLLISION CANNOT BE MANUFACTURED, so this case drives the branch through
// the cache's forced-hash test seam instead. Without it the case could not go red for its
// own name: two states that differ by one float ULP have different hashes, the confirm is
// never consulted, and the memcmp branch, the Collisions counter and the probe's
// continuation past a rejected entry were all completely uncovered.
TEST(SamplerEmit, AHashCollisionDoesNotAliasTwoSamplerStates) {
EmitterScope scope;
Uint64 payload = 0;
SamplerParameters first = DistinctParameters();
SamplerParameters second = DistinctParameters();
second.maxLod = second.maxLod + 0.0009765625f; // one representable step, nothing else moves
ASSERT_NE(std::memcmp(&first, &second, sizeof(SamplerParameters)), 0);
// ONE hash for two different values, which is exactly what a collision is.
constexpr Uint64 kForcedHash = 0x0123456789ABCDEFull;
const MGPipeHandle a = Cache().AcquireWithForcedHashForTest(first, kForcedHash, payload);
const MGPipeHandle b = Cache().AcquireWithForcedHashForTest(second, kForcedHash, payload);
EXPECT_NE(a, b) << "reusing the handle would filter one state with the other's parameters";
EXPECT_EQ(Cache().GetCounters().Collisions, 1u) << "and the rejection is counted, not silent";
EXPECT_EQ(Cache().GetCounters().Mints, 2u);
EXPECT_EQ(Cache().Size(), 2u);
// AND THE PROBE KEEPS GOING PAST THE REJECTED ENTRY rather than stopping at the first
// hash match: the first value is still resident behind the colliding one, so asking for
// it again is a HIT and not a third mint. Stopping at the first match would mint a
// duplicate CSO for a value the cache already holds.
const MGPipeHandle again = Cache().AcquireWithForcedHashForTest(first, kForcedHash, payload);
EXPECT_EQ(again, a);
EXPECT_EQ(Cache().GetCounters().Mints, 2u);
EXPECT_EQ(Cache().GetCounters().Hits, 1u);
// The ordinary path, for completeness: two states that differ in ONE field never share
// a handle and never reach the confirm at all.
Cache().ResetForTest();
Cache().ResetCounters();
const MGPipeHandle plainFirst = Cache().Acquire(first, payload);
const MGPipeHandle plainSecond = Cache().Acquire(second, payload);
EXPECT_NE(plainFirst, plainSecond);
EXPECT_EQ(Cache().GetCounters().Collisions, 0u);
}
// ============================ D-F2: the sampler view ============================
TEST(SamplerEmit, AnUnchangedTextureReIssuesNothing) {
EmitterScope scope;
GLuint name = 0;
const SharedPtr<ITextureObject> texture = MakeCompleteTexture(name, 4);
ASSERT_TRUE(texture);
Uint64 payload = 0;
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId());
const MGPipeHandle view = Emitter().AcquireSamplerView(*texture, handle, payload);
ASSERT_FALSE(MGPipeHandleIsNull(view));
EXPECT_EQ(Emitter().ViewCreateCount(), 1u);
// THE VERSION-FIRST SKIP: nothing moved, so nothing is hashed, copied or emitted.
EXPECT_EQ(Emitter().AcquireSamplerView(*texture, handle, payload), view);
EXPECT_EQ(Emitter().ViewCreateCount(), 1u);
}
TEST(SamplerEmit, AViewIsReIssuedOnTheSameHandleWhenItsRestrictionsMove) {
EmitterScope scope;
namespace GL = MobileGL::MG_Impl::GLImpl;
GLuint name = 0;
const SharedPtr<ITextureObject> texture = MakeCompleteTexture(name, 4);
ASSERT_TRUE(texture);
Uint64 payload = 0;
const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId());
const MGPipeHandle view = Emitter().AcquireSamplerView(*texture, handle, payload);
ASSERT_FALSE(MGPipeHandleIsNull(view));
ASSERT_EQ(Emitter().ViewCreateCount(), 1u);
const Uint64 shapeBefore = texture->GetShapeVersion();
GL::BindTexture(GL_TEXTURE_2D, name);
GL::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 8, 8, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
ASSERT_NE(texture->GetShapeVersion(), shapeBefore) << "the storage really has to move";
// THE SAME HANDLE, a second record. Gen increments only on slot reuse and never on a
// respecify, so re-issuing on the same handle is legal and is what keeps a server's
// twin table from minting a second identity for one texture.
const MGPipeHandle reissued = Emitter().AcquireSamplerView(*texture, handle, payload);
EXPECT_EQ(reissued, view);
EXPECT_EQ(Emitter().ViewCreateCount(), 2u);
EXPECT_EQ(Emitter().LastCreatedView().Cso, view);
EXPECT_EQ(Emitter().LastCreatedView().Texture, handle);
EXPECT_EQ(Emitter().LastCreatedView().Target, static_cast<Uint8>(TextureTarget::Texture2D));
EXPECT_EQ(Emitter().LastCreatedView().InternalFormat, static_cast<Uint32>(texture->GetFormat()));
// An ordinary texture carries no view restriction, and zero is that statement rather
// than a second spelling of it: glTextureView always writes NumLevels >= 1.
EXPECT_EQ(Emitter().LastCreatedView().NumLevels, 0u);
EXPECT_EQ(Emitter().LastCreatedView().NumLayers, 0u);
}
// ============================ D-G: the two unit sets ============================
Uint MakeSamplerProgram() {
namespace GL = MobileGL::MG_Impl::GLImpl;
static const char* kVs = "#version 330 core\nvoid main(){ gl_Position = vec4(0.0); }\n";
static const char* kFs =
"#version 330 core\n"
"uniform sampler2D sampled;\n"
"out vec4 color;\n"
"void main(){ color = texture(sampled, vec2(0.0)); }\n";
const Uint program = GL::CreateProgram();
const Uint vs = GL::CreateShader(GL_VERTEX_SHADER);
GL::ShaderSource(vs, 1, &kVs, nullptr);
GL::CompileShader(vs);
GL::AttachShader(program, vs);
const Uint fs = GL::CreateShader(GL_FRAGMENT_SHADER);
GL::ShaderSource(fs, 1, &kFs, nullptr);
GL::CompileShader(fs);
GL::AttachShader(program, fs);
GL::LinkProgram(program);
return program;
}
// ARCHITECTURE's third merge rule: the client emits the PROGRAM-RESOLVED set. A unit the
// shader does not sample carries no view, whatever is bound to it - which is exactly the
// gallium one-view-per-slot resolved form, and exactly what DirectGLES arrives at by
// asking the program which of a unit's aliased bindings is the sampled one.
TEST(SamplerEmit, OnlyTheProgramResolvedUnitsAreEmitted) {
EmitterScope scope;
namespace GL = MobileGL::MG_Impl::GLImpl;
const Uint program = MakeSamplerProgram();
GLint linked = 0;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
GL::UseProgram(program);
const GLint location = GL::GetUniformLocation(program, "sampled");
ASSERT_GE(location, 0);
GL::Uniform1i(location, 3);
GLuint sampledName = 0;
GLuint unsampledName = 0;
const SharedPtr<ITextureObject> sampled = MakeCompleteTexture(sampledName, 4);
const SharedPtr<ITextureObject> unsampled = MakeCompleteTexture(unsampledName, 4);
BindTextureToUnit(3, sampled);
BindTextureToUnit(5, unsampled);
ASSERT_GT(Emitter().EmitSamplerViews(Ctx()), 0u);
ASSERT_EQ(Emitter().ViewSetCount(), 1u);
ASSERT_GE(Emitter().LastSamplerViews().Count, 6u);
EXPECT_EQ(Emitter().LastSamplerViews().Start, 0u);
const MGPBoundView& resolved = Emitter().LastBoundViews()[3];
EXPECT_EQ(resolved.Unit, 3u);
EXPECT_FALSE(MGPipeHandleIsNull(resolved.Texture)) << "unit 3 is what the shader samples";
EXPECT_FALSE(MGPipeHandleIsNull(resolved.View));
const MGPBoundView& ignored = Emitter().LastBoundViews()[5];
EXPECT_EQ(ignored.Unit, 5u);
EXPECT_TRUE(MGPipeHandleIsNull(ignored.Texture))
<< "unit 5 has a texture bound but no sampler uniform resolves to it";
EXPECT_TRUE(MGPipeHandleIsNull(ignored.View));
GL::UseProgram(0);
}
TEST(SamplerEmit, AnUnchangedSetEmitsNothing) {
EmitterScope scope;
GLuint name = 0;
const SharedPtr<ITextureObject> texture = MakeCompleteTexture(name, 4);
BindTextureToUnit(1, texture);
Emitter().EmitSamplerViews(Ctx());
const Uint64 after = Emitter().ViewSetCount();
// The suppressor's whole job: an unchanged resolved set is not sent again. Without it
// this would be a several-hundred-byte variable-length record per batch, because a
// redundant re-bind moves the bind generation and the dirty bit with it.
Emitter().EmitSamplerViews(Ctx());
EXPECT_EQ(Emitter().ViewSetCount(), after);
}
TEST(SamplerEmit, ARedundantRebindOfTheSameSamplerEmitsNothing) {
// THE SCOPE IS LOAD-BEARING and not decoration: without it this case's counter
// assertions would be reading whatever the previous case's destructor happened to
// leave, which is a suite-order dependence of exactly the class the padding bug was.
EmitterScope scope;
const SharedPtr<MG_State::GLState::SamplerObject>& sampler = Ctx().CreateSamplerObject(4131);
ASSERT_TRUE(sampler);
Ctx().GetTextureUnitObject(2).SetSamplerObject(sampler);
Ctx().NoteTextureUnitTouched(2);
ASSERT_GT(Emitter().EmitSamplerStates(Ctx()), 0u);
ASSERT_EQ(Emitter().StateSetCount(), 1u);
ASSERT_GE(Emitter().LastSamplerStates().Count, 3u);
EXPECT_FALSE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[2]));
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[0]))
<< "a unit with no sampler object carries the null handle, and the texture's "
"built-in sampler then applies exactly as today";
// 26.2's idiom: the same sampler object re-bound at every texture-unit switch. The bind
// generation moves, so the dirty bit fires and this emitter runs - and emits nothing.
Ctx().BumpTextureBindGeneration();
Emitter().EmitSamplerStates(Ctx());
EXPECT_EQ(Emitter().StateSetCount(), 1u);
EXPECT_EQ(Cache().GetCounters().Mints, 1u) << "and it mints no second CSO either";
Ctx().GetTextureUnitObject(2).SetSamplerObject(SharedPtr<SamplerObject>());
}
// ==================== ID-17: the reference count on a cache entry ====================
// A BOUND SAMPLER STATE HOLDS A REFERENCE WHILE IT IS BOUND. The set the applier holds is
// "the last set as received" and outlives the pass that sent it, so a handle in it must not
// become the LRU's victim - nothing re-emits bind_sampler_states when a CSO is evicted,
// because an eviction is not a state change and no dirty bit fires for it.
//
// AND THE REFERENCE DOES NOT ACCUMULATE. Every Acquire takes one, and this emitter runs
// once per firing of the sampler bit - which 26.2 makes happen at every texture-unit
// switch - so an emitter that took a reference per pass and never gave one back would pin
// every CSO it ever bound for the life of the process.
TEST(SamplerEmit, ABoundSamplerStateHoldsItsCsoUntilTheUnitMoves) {
EmitterScope scope;
const SharedPtr<SamplerObject>& sampler = Ctx().CreateSamplerObject(4137);
ASSERT_TRUE(sampler);
Ctx().GetTextureUnitObject(2).SetSamplerObject(sampler);
Ctx().NoteTextureUnitTouched(2);
ASSERT_GT(Emitter().EmitSamplerStates(Ctx()), 0u);
const MGPipeHandle bound = Emitter().LastSamplerStateHandles()[2];
ASSERT_FALSE(MGPipeHandleIsNull(bound));
EXPECT_EQ(Cache().RefCountOf(bound), 1u) << "the applier's standing set names this handle";
Ctx().BumpTextureBindGeneration();
Emitter().EmitSamplerStates(Ctx());
EXPECT_EQ(Cache().RefCountOf(bound), 1u) << "one binding is one reference, not one per pass";
// The unit stops naming it, and the reference goes with the binding rather than with
// the entry: the value is still worth caching, it is merely evictable again.
Ctx().GetTextureUnitObject(2).SetSamplerObject(SharedPtr<SamplerObject>());
Emitter().EmitSamplerStates(Ctx());
EXPECT_TRUE(MGPipeHandleIsNull(Emitter().LastSamplerStateHandles()[2]));
EXPECT_EQ(Cache().RefCountOf(bound), 0u);
EXPECT_TRUE(Cache().RecordIsPublished(bound)) << "released is not evicted";
}
// THE FINDING ID-17 RULES ON. MGPTextureParams::BuiltinSampler names a sampler CSO out of
// this cache, the applier deliberately does NOT resolve that handle (an unresolvable one is
// an ordering fact, not a corrupt one), and set_texture_params is re-emitted only when a
// texture's parameters move - so an LRU eviction of a built-in sampler's entry would leave
// a published record naming a handle whose slot has been re-handed out to a different
// value, with nothing that refuses, counts, logs or re-emits. The capacity argument closes
// only the intra-pass case; the reference count is what closes this one.
TEST(SamplerEmit, AReferencedCsoIsNeverTheLruVictim) {
EmitterScope scope;
Uint64 payload = 0;
const SamplerParameters pinnedParams = DistinctParameters();
const MGPipeHandle pinned = Cache().Acquire(pinnedParams, payload);
ASSERT_FALSE(MGPipeHandleIsNull(pinned));
ASSERT_EQ(Cache().RefCountOf(pinned), 1u);
// A CTS sampler sweep, in miniature: twice the cache's capacity in distinct values,
// none of which anything keeps naming. The LRU has to run, hard.
SamplerParameters filler = DistinctParameters();
for (SizeT i = 0; i < kMGPipeSamplerCsoCacheCapacity * 2; ++i) {
filler.minLod = static_cast<float>(i) + 0.5f;
Cache().Release(Cache().Acquire(filler, payload));
}
ASSERT_GT(Cache().GetCounters().Evictions, 0u) << "the LRU really has to have run";
EXPECT_EQ(Cache().GetCounters().OverCapacityMints, 0u)
<< "there were unreferenced entries to take, so nothing had to grow";
EXPECT_TRUE(Cache().RecordIsPublished(pinned))
<< "a handle a standing record names is not the LRU's to take";
Uint64 second = 0;
EXPECT_EQ(Cache().Acquire(pinnedParams, second), pinned);
EXPECT_EQ(second, 0u) << "and it was a hit, not a re-mint under the same value";
Cache().Release(pinned);
Cache().Release(pinned);
}
// AND WHEN EVERY ENTRY IS PINNED THE CACHE GROWS AND SAYS SO. Growing is the safe
// direction - a slot too many costs memory, a handle pulled out from under a live record
// costs correctness - and OverCapacityMints is a RECORDED NUMBER rather than a gate, so a
// workload that pins more than the capacity is visible instead of being wrong.
TEST(SamplerEmit, AFullyPinnedCacheMintsBeyondItsCapacityAndCountsIt) {
EmitterScope scope;
Uint64 payload = 0;
SamplerParameters params = DistinctParameters();
Vector<MGPipeHandle> held;
for (SizeT i = 0; i < kMGPipeSamplerCsoCacheCapacity; ++i) {
params.minLod = static_cast<float>(i) + 0.5f;
held.push_back(Cache().Acquire(params, payload));
}
ASSERT_EQ(Cache().Size(), kMGPipeSamplerCsoCacheCapacity);
ASSERT_EQ(Cache().GetCounters().Evictions, 0u);
ASSERT_EQ(Cache().GetCounters().OverCapacityMints, 0u);
params.minLod = -12.5f; // one more distinct value, with nothing evictable
const MGPipeHandle extra = Cache().Acquire(params, payload);
EXPECT_FALSE(MGPipeHandleIsNull(extra));
EXPECT_EQ(Cache().Size(), kMGPipeSamplerCsoCacheCapacity + 1);
EXPECT_EQ(Cache().GetCounters().Evictions, 0u) << "no pinned entry may be taken";
EXPECT_EQ(Cache().GetCounters().OverCapacityMints, 1u) << "and the growth is counted";
for (const MGPipeHandle& handle : held) {
EXPECT_TRUE(Cache().RecordIsPublished(handle));
Cache().Release(handle);
}
Cache().Release(extra);
}
// THE COUNT IS PER HANDLE, NOT PER HOLDER, and this is what makes that visible to whoever
// holds one. Entries are content-addressed and shared by design - package B's texture-params
// record and this file's own bind_sampler_states name the SAME handle when the values match,
// each owing exactly one Release - so a holder that releases twice takes the OTHER holder's
// pin and the LRU may then evict a handle a published record still names, with nothing to
// refuse and nothing to re-emit. The cache cannot repair that, so it counts it; an assert
// could not, because MOBILEGL_ASSERT compiles out at INFO, i.e. in every build that runs.
TEST(SamplerEmit, AReleaseThisCacheNeverHandedOutIsCountedRatherThanAbsorbed) {
EmitterScope scope;
Uint64 payload = 0;
const SamplerParameters params = DistinctParameters();
const MGPipeHandle cso = Cache().Acquire(params, payload);
ASSERT_FALSE(MGPipeHandleIsNull(cso));
ASSERT_EQ(Cache().RefCountOf(cso), 1u);
ASSERT_EQ(Cache().GetCounters().UnknownReleases, 0u);
ASSERT_EQ(Cache().GetCounters().UnderflowedReleases, 0u);
// A HANDLE THIS CACHE NEVER MINTED: a stale one from before a reset, or one of another
// kind passed by mistake. It falls off the end of the probe and used to leave no trace
// at all - Releases counts only releases that found an entry.
Cache().Release(MGPipeHandle{cso.Slot + 4096u, cso.Gen});
EXPECT_EQ(Cache().GetCounters().UnknownReleases, 1u);
EXPECT_EQ(Cache().RefCountOf(cso), 1u) << "and it took nobody else's pin on the way";
// A SECOND RELEASE OF A REFERENCE ONLY ONE HOLDER OWED. The count does not underflow -
// that half was already right - but the attempt is now named.
Cache().Release(cso);
EXPECT_EQ(Cache().RefCountOf(cso), 0u);
EXPECT_EQ(Cache().GetCounters().UnderflowedReleases, 0u);
Cache().Release(cso);
EXPECT_EQ(Cache().RefCountOf(cso), 0u) << "no underflow";
EXPECT_EQ(Cache().GetCounters().UnderflowedReleases, 1u);
// A NULL HANDLE IS NEITHER: an out-of-window unit holds one and releasing it is the
// ordinary no-op the reconciliation depends on.
const auto before = Cache().GetCounters();
Cache().Release(kMGPipeNullHandle);
EXPECT_EQ(Cache().GetCounters().UnknownReleases, before.UnknownReleases);
EXPECT_EQ(Cache().GetCounters().Releases, before.Releases);
// NOTHING WAS EVICTED WHILE REFERENCED, which is the ID-17 invariant the same round
// turned from a compiled-out assert into a number.
EXPECT_EQ(Cache().GetCounters().ReferencedEvictions, 0u);
}
// FINAL REVIEW M-A: THE SAMPLER-VIEW RESOLUTION IS D-A4's PRODUCER OF kMGPipeBindSampler.
// "Any texture the sampler-view resolution names in an emitted MGPBoundView" carries the
// sticky bit from then on; a texture bound to a unit no sampler uniform resolves does not.
// Nothing produced the bit before the fix round.
TEST(SamplerEmit, AResolvedSamplerViewMarksItsTextureAsSamplerBound) {
EmitterScope scope;
MGPipeTextureEmitterInstance().ResetForTest();
namespace GL = MobileGL::MG_Impl::GLImpl;
const Uint program = MakeSamplerProgram();
GLint linked = 0;
GL::GetProgramiv(program, GL_LINK_STATUS, &linked);
ASSERT_EQ(linked, GL_TRUE);
GL::UseProgram(program);
const GLint location = GL::GetUniformLocation(program, "sampled");
ASSERT_GE(location, 0);
GL::Uniform1i(location, 3);
GLuint sampledName = 0;
GLuint unsampledName = 0;
const SharedPtr<ITextureObject> sampled = MakeCompleteTexture(sampledName, 4);
const SharedPtr<ITextureObject> unsampled = MakeCompleteTexture(unsampledName, 4);
BindTextureToUnit(3, sampled);
BindTextureToUnit(5, unsampled);
ASSERT_GT(Emitter().EmitSamplerViews(Ctx()), 0u);
const MGPBoundView& resolved = Emitter().LastBoundViews()[3];
ASSERT_FALSE(MGPipeHandleIsNull(resolved.Texture));
EXPECT_NE(MGPipeTextureEmitterInstance().TextureBindMask(resolved.Texture) & kMGPipeBindSampler, 0)
<< "the texture a sampler view was resolved for does not carry kMGPipeBindSampler";
const MGPipeHandle unsampledHandle =
MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, unsampled->GetLifetimeId());
if (!MGPipeHandleIsNull(unsampledHandle)) {
EXPECT_EQ(MGPipeTextureEmitterInstance().TextureBindMask(unsampledHandle) & kMGPipeBindSampler, 0)
<< "a texture no sampler uniform resolves to was marked sampler-bound";
}
GL::UseProgram(0);
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
@@ -535,6 +1149,11 @@ int main(int argc, char** argv) {
_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
// ONE process-wide context for the whole suite, because half these cases need a really
// linked program and glslang lives behind this call. Each case uses GL names of its own.
MobileGL::Initialize();
#endif
::testing::InitGoogleTest(&argc, argv);
const int rc = RUN_ALL_TESTS();
File diff suppressed because it is too large Load Diff
+100
View File
@@ -80,6 +80,11 @@ namespace {
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) \
@@ -232,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)
// ===================================================================================
@@ -751,6 +789,68 @@ namespace {
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)
// ===================================================================================
+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";
}
@@ -32,7 +32,11 @@
#include "Includes.h"
#include <MG_State/GLState/BufferState/BufferObject.h>
#include <MG_State/GLState/FramebufferState/FramebufferObject.h>
#include <MG_State/GLState/ProgramState/ProgramObject.h>
#include <MG_State/GLState/RenderbufferState/RenderbufferObject.h>
#include <MG_State/GLState/SamplerState/SamplerObject.h>
#include <MG_State/GLState/TextureState/TextureObject2D.h>
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
using namespace MobileGL;
@@ -156,3 +160,69 @@ TEST(ObjectLifetimeIdTest, RenderbufferObjectAtARecycledAddressCarriesAFreshLife
TEST(ObjectLifetimeIdTest, LiveRenderbufferObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::RenderbufferObject>("RenderbufferObject");
}
// P4a mints a client handle off the lifetime id of FOUR more object classes - a texture, a
// framebuffer, a sampler and a program - and resolves every one of their death paths through
// the same lifetimeId -> slot map. So the property these cases prove stops being a statement
// about two backend memos and becomes the thing the whole handle space rests on: a recycled
// heap address must not reproduce a handle, because the applier's record and the backend's twin
// are both keyed on one.
//
// The texture case is additionally a CHURN check that costs nothing extra: under a push build
// each round mints a Texture slot in the constructor and returns it in the destructor, so 64
// rounds that leaked would be visible to the leak cases the gates package owns.
TEST(ObjectLifetimeIdTest, TextureObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::TextureObject2D;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<TextureObject2D>("TextureObject2D");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, LiveTextureObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::TextureObject2D>("TextureObject2D");
}
TEST(ObjectLifetimeIdTest, FramebufferObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::FramebufferObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<FramebufferObject>("FramebufferObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, LiveFramebufferObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::FramebufferObject>("FramebufferObject");
}
TEST(ObjectLifetimeIdTest, SamplerObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::SamplerObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<SamplerObject>("SamplerObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, LiveSamplerObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::SamplerObject>("SamplerObject");
}
TEST(ObjectLifetimeIdTest, ProgramObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::ProgramObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<ProgramObject>("ProgramObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, LiveProgramObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::ProgramObject>("ProgramObject");
}
+152
View File
@@ -517,3 +517,155 @@ TEST(RingTest, DoorbellHandoffWakesBothSidesOnEveryPublish) {
EXPECT_TRUE(ok.load());
EXPECT_TRUE(ring.Invariants());
}
// ---------------------------------------------------------------------------
// P5 R-9: the five watermarks and the pad rule, from Ring.h's header comment.
//
// Nothing in the tree advanced any of the five before P5 - InitRingControl zeroed
// them and that was all - so these five cases pin the RULES against the sessions
// that are about to start writing them, rather than testing today's (absent)
// writers. Each one is the negative control for one sentence of that comment.
// ---------------------------------------------------------------------------
// R-9, sentence 0: all five start at zero, so "has not moved" and "moved to zero"
// are the same state and a waiter that starts before its peer cannot be fooled by
// a stale non-zero value left over from a previous session.
TEST(RingTest, WatermarksAreAllZeroUntilSomeoneAdvancesThem) {
alignas(4096) RingControl control{};
InitRingControl(control);
EXPECT_EQ(control.submittedSeq.load(), 0u);
EXPECT_EQ(control.appliedSeq.load(), 0u);
EXPECT_EQ(control.retiredSeq.load(), 0u);
EXPECT_EQ(control.completedFrameSerial.load(), 0u);
EXPECT_EQ(control.presentAckSerial.load(), 0u);
// ... while the two GENERATIONS start at one, because for them zero means
// "uninitialized" and must never be a legal value. The two conventions are
// opposite on purpose and are next to each other in the same struct.
EXPECT_EQ(control.serverEpoch.load(), 1u);
EXPECT_EQ(control.ringGeneration.load(), 1u);
}
// R-9, "every wait is >=, never ==". Both sides advance in jumps - a consumer that
// applies two records before republishing, a server that completes two frames in one
// poll - so an equality test misses its wakeup and the waiter hangs until the next
// coincidence. This case is that hang, made deterministic.
TEST(RingTest, AWatermarkWaiterMustTestGreaterOrEqualRatherThanEqual) {
alignas(4096) RingControl control{};
InitRingControl(control);
const std::uint64_t mySeq = 7;
// The peer jumps straight past the value this waiter cares about.
control.appliedSeq.store(mySeq + 1, std::memory_order_release);
const std::uint64_t seen = control.appliedSeq.load(std::memory_order_acquire);
EXPECT_FALSE(seen == mySeq) << "an equality waiter is still asleep at this point";
EXPECT_TRUE(seen >= mySeq) << "the >= waiter this contract mandates has been released";
}
// R-9, appliedSeq's row: advanced by the consumer for EVERY SINGLE RECORD, and P5
// forbids the 64-record batching the ring was designed for, because the verb barrier
// and every reply wait read it. The invariant that must hold after each Pop is
// `appliedSeq == records applied so far` - not "eventually", every time.
TEST(RingTest, AppliedSeqAdvancesOncePerRecordAndIsNeverBatchedInP5) {
RingFixture ring(1024);
constexpr int kRecords = 12;
for (int i = 0; i < kRecords; ++i) {
ASSERT_TRUE(ring.WriteRecord(static_cast<std::uint16_t>(i + 1), 16,
static_cast<std::uint8_t>(i)));
}
std::uint64_t applied = 0;
RingRecordView view{};
while (ring.Consumer().Pop(view)) {
++applied;
ring.Control().appliedSeq.store(applied, std::memory_order_release);
// The reader's guarantee, checked at EVERY record rather than at the end:
// a batched watermark would sit at 0 here for 63 of every 64 iterations,
// and a client barrier reading it would block on work that already ran.
EXPECT_EQ(ring.Control().appliedSeq.load(std::memory_order_acquire), applied);
}
EXPECT_EQ(applied, static_cast<std::uint64_t>(kRecords));
EXPECT_TRUE(ring.Invariants());
}
// R-9, "batching may only make a watermark LATE, never early". retiredSeq is the one
// the staging allocator reclaims behind, so a value published ahead of the actual
// drain hands live bytes back to the producer. Late is merely slow; early is a
// use-after-free that nothing on this ring checksums.
TEST(RingTest, ALazyWatermarkMayTrailTheWorkButMustNeverLeadIt) {
RingFixture ring(1024);
constexpr int kRecords = 8;
for (int i = 0; i < kRecords; ++i) {
ASSERT_TRUE(ring.WriteRecord(static_cast<std::uint16_t>(i + 1), 16,
static_cast<std::uint8_t>(i)));
}
std::uint64_t drained = 0;
RingRecordView view{};
while (ring.Consumer().Pop(view)) {
++drained;
// A deliberately lazy publisher: only every third record. This is legal.
if (drained % 3 == 0) {
ring.Control().retiredSeq.store(drained, std::memory_order_release);
}
EXPECT_LE(ring.Control().retiredSeq.load(std::memory_order_acquire), drained)
<< "retiredSeq ran ahead of the drain; those staged bytes are still live";
}
// Trailing at the end is fine and is what "late" means.
EXPECT_LE(ring.Control().retiredSeq.load(), static_cast<std::uint64_t>(kRecords));
EXPECT_TRUE(ring.Invariants());
}
// R-9's last sentence, and the one with no other detector: kRecPad DOES NOT ADVANCE
// SEQ. A wrap filler is framing - no opcode, no payload, no reply slot - so a side
// that counts it drifts from the side that does not, by one per wrap, for ever. And
// because seq IS the reply-slot id (R-3), a drifted seq reads ANOTHER CALL'S ANSWER
// instead of failing. Here the ring is sized so the last record cannot fit before the
// wrap boundary, which forces the producer to emit a filler; the consumer must count
// the records and not the filler.
TEST(RingTest, AWrapFillerDoesNotAdvanceTheRecordSequence) {
RingFixture ring(256);
constexpr std::uint64_t kPayload = 56; // 8-byte header + 56 = 64 per record
// Three records fill 192 of 256 bytes; the fourth needs 64 and only 64 remain, so
// it lands exactly at the boundary. The fifth is what forces the filler.
std::uint64_t written = 0;
for (int i = 0; i < 3; ++i) {
ASSERT_TRUE(ring.WriteRecord(static_cast<std::uint16_t>(i + 1), kPayload,
static_cast<std::uint8_t>(i)));
++written;
}
const std::uint64_t headAfterThree = ring.Producer().LocalHead();
std::uint64_t popped = 0;
RingRecordView view{};
while (ring.Consumer().Pop(view)) {
// Pop skips fillers by contract, so a pad must never reach a caller that is
// about to number it. If one ever does, that is the drift itself.
EXPECT_EQ(view.flags & kRecPad, 0u) << "a wrap filler reached the record counter";
EXPECT_NE(view.kind, kRingPadRecordKind);
++popped;
}
EXPECT_EQ(popped, written) << "the consumer numbered something the producer did not send";
ring.Consumer().PublishApplied();
ring.Consumer().PublishRetired();
// Now drive the producer across the wrap and prove a filler really was emitted:
// the head advances by MORE than the records' own bytes, and that surplus is the
// pad. The record count still has to match.
for (int i = 0; i < 3; ++i) {
ASSERT_TRUE(ring.WriteRecord(static_cast<std::uint16_t>(i + 10), kPayload,
static_cast<std::uint8_t>(i + 10)));
++written;
}
const std::uint64_t headAfterSix = ring.Producer().LocalHead();
EXPECT_GE(headAfterSix - headAfterThree, 3u * (kPayload + sizeof(RingRecordHeader)));
while (ring.Consumer().Pop(view)) {
EXPECT_EQ(view.flags & kRecPad, 0u) << "a wrap filler reached the record counter";
++popped;
}
EXPECT_EQ(popped, written)
<< "the two sides' sequence spaces have drifted by the fillers between them";
EXPECT_TRUE(ring.Invariants());
}
+5 -1
View File
@@ -179,7 +179,7 @@ namespace MobileGL::MG_Util::PipeStats {
#if MOBILEGL_PIPE_PUSH
"render-state-cso-mints", "render-state-cso-binds", "map-persistent-roundtrips",
"framebuffer-emissions", "sampler-view-emissions", "sampler-state-emissions",
"shader-image-emissions", "client-tex-upload-emissions",
"shader-image-emissions", "client-tex-upload-emissions", "tex-remint-pulls",
#endif
};
const char* const kGateNames[kGateCount] = {
@@ -453,6 +453,10 @@ namespace MobileGL::MG_Util::PipeStats {
line += " sie=" + std::to_string(calls[static_cast<Uint32>(CallClass::ShaderImageEmissions)]);
line += " ctu=" +
std::to_string(calls[static_cast<Uint32>(CallClass::ClientTextureUploadEmissions)]);
// trp is the texture-remint pull count (ROADMAP open question 2): every one is a texture
// Espryt had already allocated and then had to re-mint image-bindable, replaying its
// levels from the client's shadow, because ImageBindableHint reached it too late.
line += " trp=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureRemintPulls)]);
#endif
line += "] gates[";
for (Uint32 i = 0; i < kGateCount; ++i) {
+8
View File
@@ -156,6 +156,14 @@ namespace MobileGL::MG_Util::PipeStats {
// hides is ~+6 ms/frame, so an emission-shape divergence has to be a difference of two
// numbers rather than something only a GPU can see.
ClientTextureUploadEmissions,
// THE TEXTURE-REMINT PULL RATE (ROADMAP open question 2; P4a final review M-A). Counted
// by Espryt once per transition in which a texture that ALREADY HAD backend storage is
// re-minted image-bindable and its defined levels are replayed from the client's shadow
// (RequireImageBindableStorage) - the reach-back a split cannot make (D-M) and the one
// ImageBindableHint exists to prevent. A texture whose hint arrived before its first
// sync is allocated image-bindable up front and never counts. `trp=` on the summary
// line; the number that decides MOBILEGL_PIPE_TEXEL_RETAIN_MB's default.
TextureRemintPulls,
#endif
Count
};
+3 -1
View File
@@ -367,7 +367,9 @@ run_retrace() {
mkdir -p "${result_dir}"
# A repeat run of a case the previous invocation already installed and pushed only needs
# the on-device copy back into a fresh app output directory.
if [ "${reuse_fixture}" -eq 0 ]; then
# MOBILEGL_TRACE_SKIP_INSTALL=1: the caller installed the APK itself (ColorOS shows an install-confirmation
# dialog whose tap drops adb for a few seconds, so the install has to be done and settled outside a run).
if [ "${reuse_fixture}" -eq 0 ] && [ "${MOBILEGL_TRACE_SKIP_INSTALL:-0}" != "1" ]; then
"${ADB}" install -r "$(host_path_for_adb "${apk_file}")"
fi
copy_fixture_to_app
+14 -7
View File
@@ -62,6 +62,8 @@
CSO 在 client 侧内容寻址(Mesa `cso_cache` 先例):每类一张 `ska::flat_hash_map<xxHash, MGPipeHandle>`,容量上限 render-state 64 / vertex-elements 1024 / sampler 256 / sampler-view 4096 / shader 跟随 `ProgramObject` 生命周期,LRU 淘汰时发 `delete_*`。两个不同 program 设置了相同状态时 server 零状态转换。
> **[deviation] D-F2P4a 落地)**:上面这行的 **sampler-view 4096 项内容寻址是 P7Magma)的形状**Espryt 这一波把 sampler **view** 做成**按纹理对象身份寻址**——一纹理一视图,按纹理自己的 lifetime id 铸造,视图限制变了就在**同一个句柄**上重发。理由是 Espryt 的视图没有可共享的驱动侧对象,内容寻址只会多一张表和一次哈希。sampler **state** 的 256 项内容寻址照做,并且 P4a 给它加了**引用计数**:`MGPTextureParams::BuiltinSampler` 指着的项不允许被 LRU 挤掉(全被引用时超容铸造,计数在 `OverCapacityMints`),否则一次驱逐就会让一条标准记录指向一个已经换代的句柄。
**[deviation] D-G1P3a 落地):vertex-elements CSO 在 P3a 是身份寻址,不是内容寻址。** Espryt 根本没有 vertex-elements CSO,它有的是**逐 VAO 的 twin**`BackendVertexArrayObject``MobileGL/MG_Backend/DirectGLES/Managers.h:957-1163`),twin 持有一个驱动 VAO 名(`:1096`)、32 个 client-array scratch buffer id`:1097`)与 32 个 fp64 scratch id`:1101`);两个格式相同的前端 VAO 不能共享它,因为驱动 VAO 同时持有 element-array 绑定与逐属性缓冲绑定,共享 CSO 会把它们变成每次 `BindVertexElements` 都要重发——严格比今天更慢。所以 P3a 逐前端 `VertexArrayObject` 铸一个 `VertexElementsCso` 句柄(配置变化时**在同一句柄上重发** `CreateVertexElements``MGPipeHandle::Gen` 只在槽位复用时递增),**这是一个命中率恒为 1 的合法内容寻址缓存**。上面那张 1024 项的内容寻址表是 **P7** 的活——Magma 的 `VertexInputStateFactory` 接管 CSO 时,`VkPipelineVertexInputStateCreateInfo` 要的正是内容寻址;它加在 P3a 同一组 `CreateVertexElements`/`BindVertexElements`/`DeleteVertexElements` 与同一个 slot 分配器**之上**P3a 的线上形状与 applier 记录都不妨碍它。
## 3. 调用目录(P0 已落地)
@@ -97,7 +99,7 @@ Flags`kNeedsAck`(调用方等 server 确认;目录里目前无条目携
- 今天 20 个 draw 入口塌成 `DrawVbo` 一条,`MGPDrawRange[]` 就是 `MultiDraw*` 族今天的形状;`Clear` 一条判别式合并 `glClear` + 4 个 `glClearBuffer*` + 4 个 `glClearNamedFramebuffer*`
- `SetSamplerViews` / `BindSamplerStates` **没有 stage 维度**:MobileGL 的纹理单元空间是合并的(`TextureState::m_textureUnits` 是 192 个单元的一个数组,每 stage 32 只是广告数字),同一单元可被两个 stage 采样;stage 只在目标 API 需要时由 server 从反射归档推导。
- `SetTextureParams` 按资源寻址、与 sampler view 分开(D10):只作 FBO attachment / image 单元 / `glCopyImageSubData` 端点的纹理没有 sampler view,但 Espryt 对 attachment 也同步纹理参数,且 `RequireImageBindableStorage` 需要在前端参数版本不动时强制重同步。
- `SetTextureParams` 按资源寻址、与 sampler view 分开(D10):只作 FBO attachment / image 单元 / `glCopyImageSubData` 端点的纹理没有 sampler view,但 Espryt 对 attachment 也同步纹理参数,且 `RequireImageBindableStorage` 需要在前端参数版本不动时强制重同步。**P4a 有意把这个缺口关掉了**Espryt 从 `SyncNeccessaryTextures` 侧补上 READ-only attachment 的参数同步),并且**证明它此前确实是坏的**:证据不是公共 GL 的图——回读模拟自己会写 `GL_DEPTH_STENCIL_TEXTURE_MODE``IsDrawSyncClean` 又在首次采样时把参数推下去,所以任何纯 GL 序列都看不见它——而是 `MG_IntegrationTest/Harness/PipeApplyPeek.{h,cpp}` 的白盒断言(读 applier 的参数记录与 Espryt 已应用状态),变异注入下 4/4 变红。
- `SetIndexBuffer` 独立于 VAO 配置版本(D5):索引 slot 重绑不移动 VAO config version。
- `SetGlobalConstants` 只覆盖默认 uniform blockD6):`globalUboScratch` 是 link phase B 的 CPU 数组,没有 GL name、没有 `BufferObject`
@@ -131,9 +133,9 @@ Flags`kNeedsAck`(调用方等 server 确认;目录里目前无条目携
| `MGPRenderStateDesc` / `MGPBindRenderState` / `MGPDynamicState` | 48 / **12** / 32 | §5.3 |
| `MGPVertexElements` | 40 | blob 同时带解析后的 `MGPVertexAttribWire[]` **和** `MGPVertexBindingPointWire[]`(P3a 落地的两个 POD 线上形,24 B / 16 B`MobileGL/MG_Pipe/MGPipeValueTypes.h:568-600`)。**两个视图都过线的理由是记录自洽,不是 stride 消歧**:前端已经把 pointer 调用的 stride 0 解析成 element size,一个活到 `VertexAttribute::Stride` 的 0 只可能来自 binding 模型(`MobileGL/MG_Pipe/MGPipeValueTypes.h:496-503`),后端从来不读 binding point`MG_Backend``VertexBufferBindingPoint` / `GetAttributeBindingIndex` / `GetAttributeRelativeOffset` 零命中);真正承重的是 `MGPVertexElements` **声明**了 `BindingPointCount`,一条不描述自己 blob 的记录会让 applier 的边界门永远无法收口。代价按配置变化付一次、不按 draw 付(blob 只搭 `CreateVertexElements`),裁掉第二个视图是 P13 的重调项。`IsLong``Type == Float64` 分开携带;`Divisor` 不在属性视图里(走 `MGPVertexBuffer::Divisor`);仅供查询的 `LegacyStride/LegacyPointer` 留在 client |
| `MGPSamplerDesc` | 32 | `SamplerParameters` 逐字节过线**含 `borderColorForm`**(三种 border color 表示永远都被数值填满,没有它后端无法在 `Iiv`/`fv``VkBorderColor` 家族间选择) |
| `MGPSamplerView` / `MGPTextureParams` | 36 / 32 | view 只带视图限制(min/num level、min/num layer、别名格式);纹理参数(base/max level、swizzle、depth-stencil mode、LOD 钳、`ForceResync`)挂在纹理对象上 |
| `MGPSamplerView` / `MGPTextureParams` | 36 / **40**P4a | view 只带视图限制(min/num level、min/num layer、别名格式);纹理参数(base/max level、swizzle、depth-stencil mode、LOD 钳、`ForceResync`)挂在纹理对象上。**[deviation] D-E1**`MGPTextureParams` 从 32 涨到 **40**,多出 `BuiltinSampler`(该纹理自带 `SamplerObject` 所对应的 sampler CSO 句柄,**不允许是空句柄**——每个 `ITextureObject` 都有一个 sampler 对象,空句柄是协议损坏而不是"没有 sampler")与第二个 resync 位 `SamplerResync`;参数本身一项没变。句柄从 client 的**内容寻址 cache** 取(`MGPipeSamplerCsoCacheInstance().Acquire`),不是按纹理身份铸的——这条缝在 P4a 里被踩了两次:客户端一度按对象身份铸、Espryt 的 twin 一度按身份查,两边都拿不到那条内容寻址的记录 |
| `MGPProgramDesc` | 192 | 逐 stage SPIR-V blob ×6 + 反射归档 blob + `StageMask`/`GlobalUboSize`/`ReservedNumSamplesOffset` + 四个状态字节,§7 |
| `MGPFramebufferState` | 304 | 8 color + depth + stencil + **client 解析后的 `ReadSurface`**(按结构消灭 read-buffer-shared-FBO 缺陷类);`MGPSurface::InternalFormat` 内联(四个跨对象 mask 推送时零查表);`ContentHash` 既是 server 的 render-pass memo 键也是 client 的发射抑制器 |
| `MGPFramebufferState` | 304 | 8 color + depth + stencil + **client 解析后的 `ReadSurface`**(按结构消灭 read-buffer-shared-FBO 缺陷类);`MGPSurface::InternalFormat` 内联(四个跨对象 mask 推送时零查表——P4a 发现只有格式不够,`MGPSurface``Pad0` 因此变成 `TextureTarget`,否则 `ShouldUseCaveatTextureFormat` 这类 mask 仍得回前端问纹理目标);`ContentHash` 既是 server 的 render-pass memo 键也是 client 的发射抑制器。**P4a:多一个 `Target` 字节,记录按绑定目标发**`Draw` / `Read` / `Both``Complete` 是后端无关的 `CheckCompleteness()` 答案,D-C3),**并且 applier 把记录按 framebuffer 句柄存成每对象一张表**外加两个"当前绑定"句柄——第四个取值 `Named = 3` 就是"这条记录描述它点名的那个 framebuffer,不动任何绑定"`BlitNamedFramebuffer` 与四个 `ClearNamedFramebuffer*` 之前正是因为只有两条绑定记录而打进了一个从未收到附件的 FBO |
| `MGPSubData` / `MGPSubRegion` | 72 / 40 | §6 |
| `MGPDrawInfo` / `MGPDrawRange` / `MGPDrawIndirect` | **56** / 12 / 40 | `Flags` 门控 `MinIndex/MaxIndex`(只在 client-memory 数组路径算)与 `XfbCpuCapturedVertices`(只在 XFB scatter 路径读)——不是每 draw 都算;`NumDraws``MGPDrawRange` 在变长尾;用户索引的 `MGHostSpan` 只在 `kDrawHasUserIndices` 时进变长尾;indirect 的 `DrawCount` 由 client 解析,server 永不读 indirect 命令块来数 draw |
| `MGPShaderBuffers` / `MGPBufferRange` | 32 / 24 | range 不内联 host span`kCapNeedsHostUboBytes` 下 Uniform 类带第二个变长尾 `MGHostSpan[HostSpanCount]`,与 range 数组下标对齐 |
@@ -209,7 +211,7 @@ GL 是每 unit 每 target 各一个绑定;shader 看见哪一个取决于 samp
- `resource_create` 在前端对象构造时发,存储由 `resource_respecify` 惰性定义;`resource_destroy` 在析构时发。三条顺序约束由 payload 表达:view 先于存储属主销毁(`ViewOf` + server keep-alive)、FBO attachment 钉住纹理(surface handle 隐含 keep-alive)、buffer texture 钉住 buffer`BufferForTexBuffer`,范围实时解析)。
- 共享组:v1 一个 screen、一个 context、一条 flow`eglMakeCurrent` 是 flow 所有权转移,在既有 `EGLOperationMutex` 下发射(顺手让 `ReleaseThread``SwapInterval` 也取该锁)。
- program pipeline 合成体:`GLContext::GetProgramForDraw()` 今天就完全在前端合成(join、签名查 cache、`Link(true)`)。tracker 拿到 `SharedPtr<ProgramObject>` 推**一个** handleslot 从 `ShaderCso` 保留高位段分配,pipeline cache 淘汰时释放 slot、`gen++`、发 `delete_shader_state`。合成体从不过线,server 不需要任何"解析后的 draw program"钩子;副带收益是阻塞的 `JoinLinkAndSpirv()` 离开 server 的 draw path。
- program pipeline 合成体**P4a 落地:`MobileGL/MG_Impl/Pipe/CompositeResolver.h`**`GLContext::GetProgramForDraw()` 今天就完全在前端合成(join、签名查 cache、`Link(true)`)。tracker 拿到 `SharedPtr<ProgramObject>` 推**一个** handleslot 从 `ShaderCso` 保留高位段分配(段基 983040,独立稠密表——按 slot 索引的向量在这个段上要 ~236 MB)pipeline cache 淘汰时释放 slot、`gen++`、发 `delete_shader_state`**释放有两条路,都必须恰好一次**:合成体自己被换掉时由 resolver 释放,程序对象死亡时由 `~ProgramObject``MGPipeEmitShaderCsoDestroyAndFree` 释放(D-H7)。**记忆按 `(ContextId, 管线 GL 名)` 键控**——resolver 是进程级单例而 GL 名是每上下文的,只按名字记会在两个上下文用同一个管线名时把对方还活着的合成体释放掉(复审在真实 make-current 序列上打出来的);`Reset()` 只清"新鲜度",不清"该释放"这件事,否则一次 make-current 之后释放路径就永久失灵。合成体从不过线,server 不需要任何"解析后的 draw program"钩子;副带收益是阻塞的 `JoinLinkAndSpirv()` 离开 server 的 draw path。
### 5.7 emulation 的归属
@@ -252,13 +254,15 @@ GL 是每 unit 每 target 各一个绑定;shader 看见哪一个取决于 samp
- `MGPSubRegion` 显式携带 `SrcRowStride/SrcSliceStride``MGPSubData::SourceIsVerbatimLevelShadow` 显式携带原来由 `uploadData == mipData` 指针比较回答的问题:"这批字节是未经转换的 level shadow 吗"。split 下 client 既不发整 level 也不在 server 留整 level 镜像,指针比较不成立;Espryt 的上传路径改为从描述符取步长,`UNPACK_ROW_LENGTH``SrcRowStride/bpp` 设。形状照抄已存在的 `UnpackStagingBlock`(ring 路径本来就紧密重打包、不发 `glPixelStorei`)。
- **dirty 归属反转**client 保留 rect 模型、维护一份发射游标、发射后清自己的标志,server 从不碰 client 的标志。安全,因为 `MG_Impl` 里没有任何 `IsStorageDirty/GetStorageDirtyRects/GetStorageDirtyRegion` 调用点(前端从不读自己的 dirty 状态)。逐 level "server 权威位"与纹理 ack 协议因此不必存在。
- 发射游标按**存储属主**键控 `(storageOwnerHandle, ownerUploadTarget, ownerLevel)``TextureObjectView` 把 dirty 查询/清除全部转发给属主并做索引重映射,view 与属主共用同一份 dirty 状态。门:通过 view 上传、经属主采样(及反向),跨 draw 边界各一次。
> **P4a 落到哪一步****排水列表(drain list)是这一波的**——client 在 validate 点把每个脏的 `(存储属主, 上传目标, level)` 发成**一条** `resource_subdata`,逐区带 `SrcOffset`/`SrcRowStride`/`SrcSliceStride``RegionCount == 0` 合法且表示"并集框就是全部"。**上面那条按存储属主键控的发射游标与索引重映射仍然是 P3b/P4b 的**,P4a 没有做。两条 P4a 学到的规矩写在这里:(1) **清 dirty 要等 applier 的 acceptance**,不是发射即清——`MGPipeApplyResourceSubData` 返回 `Bool`,被拒的记录必须让 level 留在脏表里(否则拉取路径再也不会补它);(2) **服务端自己的重新变脏是服务端的事**——`RequireImageBindableStorage` 会让一个已经上传过的 level 重新需要上传,client 的标志早就清了,所以 twin 直接重新武装 applier 记录里的待上传项。`TextureUploadShapeScenario`(形状金标)**已经建好但只记录不设门**,要等 Mali 侧的帧时增量才升级成门。
- 后端真正在 shadow 里写字节的两处——CPU 回退生成 mipRGB16F/RGB32F)与 `glCopyImageSubData` 目的地镜像——分别由 `OnTextureWriteback` 与"CopyImage 镜像搬到 client"处理。
- Unpack PBO 完全在 client 解析;压缩纹理永不到达后端;`glCopyTexSubImage*``glClearTexImage` 整体留在 client(今天就是纯前端操作:借一次 `ReadPixels` 进 CPU scratch 再写 shadow),拆分后恰好是一次阻塞 ReadPixels round trip,脏区按普通 subdata 下发。
## 7. Shader state = SPIR-V + 反射归档
- `CreateShaderState` 的 payload 是逐 stage SPIR-V + 反射归档(`LinkArtifacts` + `SpirvArtifacts` 全结构体),**不是源码**。"server 从源码重新 link"这条路显式关闭:链接真 `ProgramObject` 就链接 glslang。glslang 全在 clientSPIRV-Cross`TranspileSpirvToEssl`)全在 server,文件级切割。没有 `MOBILEGL_IPC_PROGRAM` 开关、没有 server 侧 compile pool。
- 归档机制:`Visit()` + `sizeof` 绊线(`static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE)`),一份字段表服务序列化两个方向。必须覆盖四个 `ResourceReflection`(各带 `TypeFacts`)、`uniformSamplerOrImageUnitIndex``uniformBlockBinding``shaderStorageBlockBinding`(按名字)、`explicitOpaqueUniformBindings``xfbVaryings/xfbStrides/xfbPackedStride/xfbNeedsScatteredCapture``computeLocalSize`、GS/TCS/TES 事实、`usesReservedNumSamples``uniformOffsets``XfbVarying` 带两套拼写(GL 名字 + block 实例/成员/元素)。
- 归档机制:`Visit()` + `sizeof` 绊线(`static_assert(sizeof(LinkArtifacts) == MGL_LINKARTIFACTS_SIZE)`),一份字段表服务序列化两个方向。**P4a 落地:序列化器在树里了**(`MobileGL/MG_Pipe/ProgramArtifactsCodec.{h,cpp}`),但 **monolith 只在 verify 构建里调它**——句柄臂上 server 读的仍是前端自己的归档(D-H3:制品不在 monolith 过线,编解码因此不在热路径上),真正逐帧走 codec 是 P5 之后的事。**未完的一件配套**:`ProgramArtifacts.h` 的 libc++/NDK 尺寸钉仍是惰性的(`MGL_ARTIFACT_SIZES_LIBCXX_PINNED` 没定义),也就是说 Android 构建上这个 58 成员结构还没有绊线——记在 P3b/P4b 的清单里。必须覆盖四个 `ResourceReflection`(各带 `TypeFacts`)、`uniformSamplerOrImageUnitIndex``uniformBlockBinding``shaderStorageBlockBinding`(按名字)、`explicitOpaqueUniformBindings``xfbVaryings/xfbStrides/xfbPackedStride/xfbNeedsScatteredCapture``computeLocalSize`、GS/TCS/TES 事实、`usesReservedNumSamples``uniformOffsets``XfbVarying` 带两套拼写(GL 名字 + block 实例/成员/元素)。
- **P0.5 硬前置**:反射类型今天声明在 `ProgramObject.h` 里,而它 include `ShaderObject.h`(→ glslang)与 `SpvcSession.h`(→ spirv_reflect)。P0.5 把 `TypeFacts``ResourceReflection``XfbVarying``LinkArtifacts``SpirvArtifacts` 抽到 `MG_State/GLState/ProgramState/ProgramArtifacts.h`(只 include `<Includes.h>` 与容器),8 个 includer 靠类内 `using` 别名零改动,加 CI `-H` 闭包断言(已落地,见 `ROADMAP.md` P0.5 行;`DynamicBackendParameters` 留在 `BackendObject.h`,所以闭包门 A 断言的是 `MGPipeValueTypes.h` 而不是 `MGPipeTypes.h`)。同批抽取 `MG_Pipe/MGPipeValueTypes.h``MAX_DRAW_BUFFERS``PerBufferBlendState``StencilFaceState``PixelStoreParameters``RenderStateParameters``SamplerParameters``BorderColorForm``VertexAttribute``VertexBufferBindingPoint`),它不 include `MG_State/GLState` 任何东西;`MGPipeTypes.h` 今天为此临时 include 了 `BackendObject.h``RenderState.h`(文件头注明为 P0.5 债务)。没有这一步,P7 的 `nm -D | grep glslang` 判据不可达。
- server 侧惰性特化(D-B2):后端 program 还依赖 8 个额外输入(draw FBO 的 snorm/unorm clamp mask、fragColor 广播数、storage-block 绑定签名、atomic counter 集、活的 image 格式、patch 参数;Magma 另加 FragCoord-Y-flip 的 default-FB 高度与 XFB 布局),`create_shader_state` 发布**制品**server 在 verb 时刻从已推送状态特化——正是两个后端今天的做法,也是 gallium `st_variant` 的做法。
- 后端 link/compile 失败不需要同步返回:今天只是一行 `MGLOG_E` 加 bind program 0 的空 draw`GL_LINK_STATUS` 永不撤回,同步查询由 client 从 `ProgramObject` 回答。`OnLog` 逐字复现——由此要求日志按严重级分级(§8.3)。
@@ -292,7 +296,7 @@ GL 是每 unit 每 target 各一个绑定;shader 看见哪一个取决于 samp
### 8.3 错误、ack 与日志
- 纹理分配的 OOM 在 monolith 里就已推迟到 sync 时刻(`glTexImage*`/`glTexStorage*``MarkStorageDirty`Espryt 惰性分配;连 `glRenderbufferStorage*` 也在 `SyncToBackend` 里惰性做),拆分不改变可观察行为,这批不同步 ack。
- **唯一允许同步 ack 的入口是 `glBufferStorage`(真同步分配)**`glRenderbufferStorage*` 不 ack41 个 trace fixture 里 OOM 探测惯用法出现 0 次(9 次调用散在 5 个 fixture,无一在 3 个调用内跟 `glGetError`;语料里的成功性检查是 `glCheckFramebufferStatus`client 本地作答)。**P3a 起 `ResourceRespecify` 携带 `kNeedsAck`,并且带一个逐记录谓词**:flag 是**逐调用**的静态属性,而 `ResourceRespecify` 同时服务 `glBufferData``glBufferStorage`,裸 flag 会把 Minecraft 的整块 chunk 上传变成每 store 一次往返。所以真正拍板的是 `MGPipeResourceRespecifyNeedsAck(desc) == (desc.Immutable != 0)``MobileGL/MG_Pipe/MGPipeTypes.h:680`),`PipeCalls.def:18-24` 的图例把这条规则写在目录里(调用行 `:82`),`MG_Test/Pipe/PipeCatalogueTest.cpp:552-585``ResourceRespecifyAcksOnlyImmutableStorage` 对两种惯用法各钉一次。monolith 下 ack 是 `((void)0)`(applier 只隔一次函数调用),P5 把门铃接到这个谓词上。
- **唯一允许同步 ack 的入口是 `glBufferStorage`(真同步分配)**`glRenderbufferStorage*` 不 ack41 个 trace fixture 里 OOM 探测惯用法出现 0 次(9 次调用散在 5 个 fixture,无一在 3 个调用内跟 `glGetError`;语料里的成功性检查是 `glCheckFramebufferStatus`client 本地作答)。**P3a 起 `ResourceRespecify` 携带 `kNeedsAck`,并且带一个逐记录谓词**:flag 是**逐调用**的静态属性,而 `ResourceRespecify` 同时服务 `glBufferData``glBufferStorage`,裸 flag 会把 Minecraft 的整块 chunk 上传变成每 store 一次往返。所以真正拍板的是 `MGPipeResourceRespecifyNeedsAck(desc) == (desc.Immutable != 0)``MobileGL/MG_Pipe/MGPipeTypes.h:680`),`PipeCalls.def:18-24` 的图例把这条规则写在目录里(调用行 `:82`),`MG_Test/Pipe/PipeCatalogueTest.cpp:552-585``ResourceRespecifyAcksOnlyImmutableStorage` 对两种惯用法各钉一次。monolith 下 ack 是 `((void)0)`(applier 只隔一次函数调用),P5 把门铃接到这个谓词上。**P4a 把这个谓词收窄到 buffer 目标(D-A2**:纹理与 renderbuffer 从这一波起也走 `resource_respecify`,而 `glTexStorage*``Immutable` 同样是 1——不收窄的话每一次不可变纹理分配都会变成一次同步往返。收窄之后 `ResourceRespecify` 还多了两条 P4a 的语义:一是**逐 level 作用域**(带 `MGPRespecifiedLevel` 尾,说明这次重定义的是哪一级;空指针才是"整个资源"),二是**元数据更新**(存储定义字段与已存描述符逐项相同时,只换 `BindMask`/`ImageBindableHint`,不 ack、不清待上传)——前者是终审抓出的 critical:不带 level 时 `glTexImage2D(level 1)` 会把同一纹理上已经被 applier 接受、客户端也已清了 dirty 标记的第 0 级待上传一起丢掉,画面读回全黑。
- 其余错误一律晚到,走有序的 `OnGlError`
- `OnLog` 分级:≤WARN 有损(覆盖最旧 + `eventDropped` 计数);≥ERROR 无损,加入触发 `eventRingFull` + 停止 apply 的语义事件集;每秒 ERROR 速率限制器,超限发一条 "N errors suppressed"`MGLOG_E_ONCE` 的 latch 变 per-server。理由:后端 link 失败只以一行 ERROR 呈现,统一有损会让最有诊断价值的那一行在日志压力下消失。
@@ -320,6 +324,8 @@ Magma`VulkanRenderer` 全部 memo 与 scratch、`PipelineFactory`、`ProgramF
从"不动"里移出的一项:Espryt 的 sub-rect 上传判定与跨步计算(§6,从描述符取步长)。
**P4a 的字节一致行**`scripts/p4a_untouched_regions.sh`,17 个区 / 3 个文件,8 个阴性对照按名变红;与 P3a 的十一函数并列):Espryt 的 depth-stencil 采样模拟与格式 caveat 两族——`ShouldUseCaveatTextureFormat``BackendTextureFormatAddsAlpha``StageBlocksIntoUnpackRing``UnpackRingAvailable``UnpackRingAllocate``RecomputeBackendColorSlots` 等——它们是"被推送的记录改变了输入、但算法本身不许动"的那一类,字节一致是唯一能证明这点的门。**仍在 `MG_Backend` 里的前端类型**`g_rawDepthFetchSamplerState` 是一个前端 `SamplerObject`(Espryt 自己为原始深度取样铸的,client 从没见过它,因此永远不会有记录——句柄臂上"对象即权威"的那条分支就是为它留的),它的原生化归 P3b/P4b,是句柄化之后 Espryt 侧最大的一处残留。
唯一两处必须真改的 `MG_State` 类型内部用法(都在 Magma):占位纹理(构造真的 `TextureObject2D*` 只为复用 `SyncTextureAndGetDescriptor(ITextureObject&)` 签名,~120 行木偶戏 → ~60 行原生 `VkImage`+view+descriptor34 个 `MOBILEGL_ASSERT(pGLContext)` 里的 9 个随之消失);两个内部 shader 烘焙(§7)。Espryt 的小号同类:`g_rawDepthFetchSamplerState` → 后端原生 sampler。
### 9.2 strangler 脚手架:`PipeInputs` + 逐 verb 填充 + poison 世代(P1
@@ -372,6 +378,7 @@ Track V 的 55% 不需要逐字段接口条目就能跑起来,所以 P2 发一
- **buffer 家族的 pre-handle 臂是 `Ops_*` 表加 `g_glesBufferBackendOps`,它们无条件编译**,不在 `MOBILEGL_PIPE_LEGACY_MEMOS` 之下——所以位 7 单独清零永远有一条真臂可跑,`NoArm` 对这个家族不可达(`Managers.cpp:2294-2298` 把这句写在代码里)。前端的十一处分发点(`MobileGL/MG_State/GLState/BufferState/BufferObject.cpp:45``:71``:87``:103``:237``:375``:438``:450``:493``:602``:656`)按 `MGPipeResourceSubsystemEnabled()` 二选一。
- **VAO twin 的前端读取臂是有条件的**pre-handle 的 `SyncToBackend` 本体与它读的那组 memo`m_syncedIndexBufferVersion` / `m_syncedIndexBufferObject` / `m_hasSyncedConfigVersion` / `m_syncedConfigVersion` / `m_syncedAttributeVersions`)都在 `MOBILEGL_PIPE_LEGACY_MEMOS` 里(`MobileGL/MG_Backend/DirectGLES/Managers.h:1109-1130`)。位 8 清零 + `LEGACY_MEMOS=0` 是**没有任何 vertex-input 臂**的配置,`Managers.cpp:2343-2351` 明确报 `PipeLegacyMemosDisabled` 而不是静默。位 8 还要求位 7(属性的缓冲 id 经资源 slot 表解析),`0x17f` 会点名拒绝并回落到 legacy 臂。
- **P4a 的六种 twin 同样有条件臂**(纹理、renderbuffer、framebuffer、sampler、sampler view、program):pre-handle 的本体与它读的那批身份 memo 都在 `MOBILEGL_PIPE_LEGACY_MEMOS` 之下,`#if !MOBILEGL_PIPE_LEGACY_MEMOS` 的分支留着但不可达(arm resolver 在第一次查找就停进程),保持**响亮**。这条开关给这一波多加了约一天:pull 构建被 `CMakeLists.txt` 强制打开它,所以每个"删掉旧 memo"的动作都只是让新臂不再读它、`sizeof` 一点不动——G1 的 0/0/0/0 正是这条的度量,真正的删除跟着 pull 路径在 P13 发生。
- **base-instance 的 ambient 作用域同在其中**`SetPendingFetchBaseInstance` / `GetPendingFetchBaseInstance` / `ScopedFetchBaseInstance``DirectGLES.cpp:5293` 的三个 scope`Managers.h:1189-1200`)。句柄臂改由 `MGPipeApplierState::VertexFetchBaseInstance` 供给,但这三个声明**不能删**:删掉会从 pull 构建移走两个符号(G1)。
P13:删 `SnapshotFromGLContext()` 的非 verify 分支、`MGB_CTX``MOBILEGL_PIPE_PUSH``MOBILEGL_PIPE_LEGACY_MEMOS`**保留 `MOBILEGL_PIPE_VERIFY` 连同它需要的 `SnapshotFromGLContext()``MG_State` include**D-B5verify 构建永不出货);三道纯度门在非 verify 构建上转绿。
@@ -595,7 +602,7 @@ CMake
| 变量 | 默认 | 说明 |
|---|---|---|
| `MOBILEGL_PIPE_PUSH` | pull 构建 `0`**push 构建 `0x1ff`**`kMGPipeSubsystemsMigratedAtP3a``MobileGL/MG_Pipe/MGPipe.h:95`,读入点 `MobileGL/ConfigLoader.cpp:257`P2 的默认是 `0x7f` = `kMGPipeSubsystemsMigratedAtP2``MGPipe.h:94`,保留作分阶段对照 | 子系统位图,十进制或 `0x`;位按 ROADMAP 顺序分配、永不复用(`MobileGL/MG_Pipe/MGPipe.h:72-95`):`0x01` 渲染状态、`0x02` pixel pack、`0x04` patch state、`0x08` vertex attrib defaults、`0x10` residual values、`0x20` Espryt slotsTrack H)、`0x40` Magma vertex inputTrack H)、**`0x80` 位 7 resourcesP3a`resource_*` 家族,`kMGPipeSubsystemResources``MGPipe.h:84`**、**`0x100` 位 8 vertex inputP3avertex elements / vertex buffers / index buffer`kMGPipeSubsystemVertexInput``MGPipe.h:85`)**;位 9..62 留给后续阶段。**位 8 依赖位 7**:属性的缓冲 id 经资源 slot 表解析,只有位 7 填那张表,所以 `0x17f` 会打一行 ERROR 点名拒绝位 8 并回落到 legacy vertex-input 臂(`MobileGL/MG_Backend/DirectGLES/Managers.cpp:2312`)。**位 63 不是子系统而是行为**:`kMGPipeBehaviourNoCsoContentAddressing``MGPipe.h:90`)关掉 client 侧 CSO 内容寻址(每次 pipeline 版本变化都铸新 CSO、永不探测 map),即 P2 的负面对照。`0` = 全 pull,但 P2 之后只有在 `MOBILEGL_PIPE_LEGACY_MEMOS` 编进了 pre-handle 臂时才是有效对照 |
| `MOBILEGL_PIPE_PUSH` | pull 构建 `0`**push 构建 `0x1fff`**`kMGPipeSubsystemsMigratedAtP4a``0x1ff` = `kMGPipeSubsystemsMigratedAtP3a``0x7f` = `...AtP2` 都保留作分阶段对照`MobileGL/MG_Pipe/MGPipe.h`,读入点 `MobileGL/ConfigLoader.cpp`)。**P4a 的四位与它们的三条依赖拒绝**:位 9 framebuffer、位 10 纹理资源、位 11 sampler、位 12 program;位 11 要位 10(每个 `MGPBoundView::Texture` / `MGPImageView::Res` 都是纹理句柄)、位 9 要位 10`MGPSurface::Res` 同理)、位 10 要位 7buffer texture 的 `BufferForTexBuffer`)**外加 P4a 加的第四条:位 10 要位 11**(`MGPTextureParams::BuiltinSampler` 是 sampler CSO 句柄,只有位 11 铸它,空句柄是 `Fatal{ProtocolCorruption}`)。**两侧都要拒**:服务端在各族的 arm resolver 里拒绝并跑旧臂,客户端在 `PipeFill.cpp` 的族门里**根本不发射**——只在服务端拒会出现"客户端已按 acceptance 清了 dirty、服务端却走旧臂"的丢上传(`0x7ff` 一度 438/491)。同一个族门上还挂着**消费者条件**:没有任何后端注册 `MGPipeResourceOps` 时四族一条不发(Magma 就是这种情形 | 子系统位图,十进制或 `0x`;位按 ROADMAP 顺序分配、永不复用(`MobileGL/MG_Pipe/MGPipe.h:72-95`):`0x01` 渲染状态、`0x02` pixel pack、`0x04` patch state、`0x08` vertex attrib defaults、`0x10` residual values、`0x20` Espryt slotsTrack H)、`0x40` Magma vertex inputTrack H)、**`0x80` 位 7 resourcesP3a`resource_*` 家族,`kMGPipeSubsystemResources``MGPipe.h:84`**、**`0x100` 位 8 vertex inputP3avertex elements / vertex buffers / index buffer`kMGPipeSubsystemVertexInput``MGPipe.h:85`)**;位 9..62 留给后续阶段。**位 8 依赖位 7**:属性的缓冲 id 经资源 slot 表解析,只有位 7 填那张表,所以 `0x17f` 会打一行 ERROR 点名拒绝位 8 并回落到 legacy vertex-input 臂(`MobileGL/MG_Backend/DirectGLES/Managers.cpp:2312`)。**位 63 不是子系统而是行为**:`kMGPipeBehaviourNoCsoContentAddressing``MGPipe.h:90`)关掉 client 侧 CSO 内容寻址(每次 pipeline 版本变化都铸新 CSO、永不探测 map),即 P2 的负面对照。`0` = 全 pull,但 P2 之后只有在 `MOBILEGL_PIPE_LEGACY_MEMOS` 编进了 pre-handle 臂时才是有效对照 |
| `MOBILEGL_PIPE_HANDLE_ABA_CONTROL` | 0 | 负面对照 C(push 构建才有,`MobileGL/Config.h:360-371`):故意打掉句柄身份,让 `HandleRecycle` 的 ABA 臂重现旧的 A-B-A 污染。它变绿即为控制失效 |
| `MOBILEGL_PIPE_VERIFY` | 0 | 逐 draw 逐字段影子比对 |
| `MOBILEGL_PIPE_STATS` | 0 | 边界计数器(§附 B) |
+107 -1
View File
@@ -462,7 +462,7 @@ $ ctest --test-dir build-push -R 'HandleRecycle' --no-tests=error -j 4 --output-
所有入表运行前后 `pin_device.sh check` 都是 PINNED(小米 rd12/Magma 崩溃后 GPU pwrlevel 被重置,其后的 sodium/create-instancing 行两臂同状态)。
**读法。** (1) **P2 的边界在 Release 下的真实代价是 +612%**`0x7f` 臂),两机两后端一致,比 -O0 表的 +8–18% 小但同量级;(2) **P3a 在 26.3 与 sodium 上几乎不再加价**P3a 臂与 P2 臂在 26.3 上相差 −0.1 +2 个百分点),**但在 rd12 上把差距从 +11% 推到 +2730%**——rd12Odin Lite 世界)每帧的 VAO/buffer 绑定切换远多于 26.326.3 的 1350 draw/帧大多复用同一 VAO),每次切换都走一遍 `set_vertex_buffers` 构造 + `ContentHash` + applier 记录 + Espryt 侧逐属性走查;Magma 上没有句柄消费者也多 15 个百分点,说明 client 侧发射本身就是大头;(3) **MC 26.3 在 Adreno 上的 p99**`ROADMAP.md:19` 点名的那个数):pull 25.46 ms → P3a 26.32 ms+3.4%),finish 开 25.48 → 26.29;对着 `MEASUREMENTS.md:87` 的采纳基线(p99 163 → 21 ms)仍在 21–26 ms 档,没有回到采纳前的形态——按口径记录,不判门;(4) `mpr`map-persistent-roundtrips,按窗口累加):26.3 两机都是 8(首窗 4,之后两次 2——都是 ≥16 MiB store 定义时的采纳),sodium 1rd12 与 create-instancing 0P2 臂上恒 0(子系统关)——G10 在设备上成立;(5) `CreateVertexElements` 每帧字节数:统计行没有这一类(`vtxc` 是 client 数组),**未测**,留给 P4a 给汇总行加类(6) 计数器(`acc/draw`、六个 memo 门、`resid=``csom/csob`)在 pull/P2/P3a 三臂间逐字相同——它们数的是代码路径,P3a 没有改它们的定义。
**读法。** (1) **P2 的边界在 Release 下的真实代价是 +612%**`0x7f` 臂),两机两后端一致,比 -O0 表的 +8–18% 小但同量级;(2) **P3a 在 26.3 与 sodium 上几乎不再加价**P3a 臂与 P2 臂在 26.3 上相差 −0.1 +2 个百分点),**但在 rd12 上把差距从 +11% 推到 +2730%**——rd12Odin Lite 世界)每帧的 VAO/buffer 绑定切换远多于 26.326.3 的 1350 draw/帧大多复用同一 VAO),每次切换都走一遍 `set_vertex_buffers` 构造 + `ContentHash` + applier 记录 + Espryt 侧逐属性走查;Magma 上没有句柄消费者也多 15 个百分点,说明 client 侧发射本身就是大头;(3) **MC 26.3 在 Adreno 上的 p99**`ROADMAP.md:19` 点名的那个数):pull 25.46 ms → P3a 26.32 ms+3.4%),finish 开 25.48 → 26.29;对着 `MEASUREMENTS.md:87` 的采纳基线(p99 163 → 21 ms)仍在 21–26 ms 档,没有回到采纳前的形态——按口径记录,不判门;(4) `mpr`map-persistent-roundtrips,按窗口累加):26.3 两机都是 8(首窗 4,之后两次 2——都是 ≥16 MiB store 定义时的采纳),sodium 1rd12 与 create-instancing 0P2 臂上恒 0(子系统关)——G10 在设备上成立;(5) `CreateVertexElements` 每帧字节数:统计行原本没有这一类(`vtxc` 是 client 数组),**P4a 已补上并测了**——汇总行的 `bytes/f[...]` 多了一个 **`csob-blob`**`cso-blob-bytes`:所有 CSO create 调用的 blob 字节,含 vertex-elements、sampler、shader)。79 例 retrace、`MOBILEGL_PIPE_STATS=1 MOBILEGL_PIPE_STATS_PERIOD=60`、push 构建(`8c458cd5`):**DirectGLES 27 个用例逐用例窗口均值的中位数 2928 B/帧、均值 42.8 KB/帧、无一为零**,最大 `rd12-odinlite` **1.06 MB/帧**(它每帧换 VAO 的次数远多于别人,正是 §20 读法 (2) 里 rd12 多花 17–19 个百分点的同一根因,这下有了字节口径);其后依次 `minecraft-1.21.4-in-world` 11.4 KB、`common-mods-inventory` 8.3 KB、`common-mods-in-world` 7.8 KB、`rei-inventory` 7.2 KB。**DirectVulkan 侧恒 0**——Magma 没有注册 `MGPipeResourceOps`,P4a 的消费者门因此让四族一条不发(见 `ARCHITECTURE.md``MOBILEGL_PIPE_PUSH` 行),这也是这个计数器第一次把那条门量化出来(6) 计数器(`acc/draw`、六个 memo 门、`resid=``csom/csob`)在 pull/P2/P3a 三臂间逐字相同——它们数的是代码路径,P3a 没有改它们的定义。
**小米 rd12 + Magma 的崩溃**:三臂(含 pull)都在启动后数秒 `SIGABRT``scudo::reportMapError``remapImpl``scudo_calloc``libMobileGL.so`0x818b14 / 0x7e2c44,已剥符号),当时 MemAvailable 6.1 GB——一次巨大或负尺寸的 calloc,在 Adreno 830 + Magma + 这条 fixture 上;pull 库与 P3a 前的 Magma 路径符号一致,所以是**既有 bug**,不入 P3a 账,已开独立任务(先符号化再修)。Oppo/Magma 与小米/Espryt 上同一 fixture 正常。
@@ -518,6 +518,8 @@ $ ctest --test-dir build-push -R 'HandleRecycle' --no-tests=error -j 4 --output-
**`g_uploadRing` 不被重置的不对称:原样保留,记为 `dev` 侧跟进。** `OnBackendContextDestroyed``MobileGL/MG_Backend/DirectGLES/Managers.cpp:2481`)对 `g_uboRing``g_unpackRing``ResetRingForNewContext``:2492-2493`),**不对 `g_uploadRing` 调**`RingAvailable``:3186`)在首次使用时按 `contextGeneration` 自愈,所以它是良性的。P3a **故意不在飞地顺手修**`ROADMAP.md:98` 那条纪律:拆分不得借机修不相关的 `dev` 问题),把它作为 `dev` 侧跟进项留在这里。
**P4a 在它旁边加第二条同类项:`ScopedDefaultUnpackState::s_synced` 没有失效路径(D-O)。** `Managers.cpp``ScopedDefaultUnpackState` 用一个**进程级**影子记住"默认 unpack 状态已经同步过",那个影子被写、被读,**却没有任何地方让它失效**——换上下文、别的代码路径自己调 `glPixelStorei`,它都不知道。P4a 既不修它也没让它更糟(句柄臂的 ring 路径一条 `glPixelStorei` 都不发,唯一的外部写点仍被 `!ringStaged` 挡着),按同一条纪律记为 `dev` 侧跟进。**两条并列的理由是同一个**:它们都是"缓存了一个事实、却没有让这个事实失效的路径",而 P4a 自己在 `Tracker.h` 上被同一类问题咬了三次(`glBindSampler` 不动位 13 的快门、SSO 下 `GetCurrentProgram()` 恒 null、`glBindImageTexture` 只换 level 时三个计数器都不动)——所以下一阶段的 brief 必须带一张"记录字段 → 写它的 setter → emitter 读的快门"的完备表,而不是让每个包各自去发现。
**`FlushPendingRangesNow` 定义一次,句柄臂另有一条自己的档梯。** G5 的第十项与 G1 的空 resize 集之间有一处真冲突:就地重构那几个 helper 会 resize 五个 pull 符号(`FlushPendingRangesNow +14` 在内),G1 不允许。落地形状是:**`FlushPendingRangesNow` 只定义一次、对 `5cb826b0` 逐字节相同**(在 `#if MOBILEGL_PIPE_PUSH``#else` 臂里,`Managers.cpp:1316`,调用点 `:1723``:2906`),句柄臂另有一个 `FlushPendingRangesFrom(twin, hostBase, size)``:1051`,调用点 `:1721``:2044``:2766``:2904`)。**在 P3a 接受档梯在 push 构建里被复制一份**(与 respecify 核心已经用过的形状相同),代价是两条梯子会漂移;对冲是 `CrossFrameBufferScenario` 的十三条加 `StreamedArenaScenario` 的两条 recycle 用例,以及 §20 的 MC 26.3 p99。**它随 pull 臂在 P13 退役**`ARCHITECTURE.md:367`)。共享模板加访问器接口的方案被否决:它同样 resize pull 符号(G1)。
**`MG_Test/Buffer/BufferTest.cpp` 的 fixture 只 scope 了一半的表。** push 构建下后端在 bring-up 同时装 `BufferBackendOps``MGPipeResourceOps`,而 `ScopedBackendOps` 只 scope 前者,于是 86 条 `BufferBackendOps` 分发用例里有 **26 条**被路由进了 pipe(症状是 `EnsureGpuResidentStorage()` 返回 `false`、mock 从没被调用过)。这是**合并缝**的典型形态——两个分支各自绿、合起来红:espryt 那边没有东西经 pipe 发射,client 那边没有东西注册表。集成者落了单 scope 的修法(fixture 现在像 `MG_Test/Pipe/ResourceEmitTest.cpp` 的 `ApplierGuard` scope applier 那样,保存 / 置空 / 恢复 pipe 表):**修完 86/86,整套单元 1619/1619**。它不削弱任何东西——那 86 条是 `BufferBackendOps` 的分发测试,pipe 侧的分发有 `ResourceEmitTest` 自己的覆盖。**跟进(不属于本阶段)**:给这个 fixture 一个 pipe 形的 mock,让同样的 86 条断言在句柄路径上再跑一遍。
@@ -526,3 +528,107 @@ $ ctest --test-dir build-push -R 'HandleRecycle' --no-tests=error -j 4 --output-
**峰值 RSSpush vs pull79 例 retrace):工具不报,所以没有数。** `~/w7/retrace_gate.py` 只有五个参数(`--tree --lib --out -j --only`),代码里没有任何 `rss` / `maxrss` / `getrusage` 引用。这个数原本是用来盯第七张 slot 表泄漏的——一个没人销毁的 buffer 会永远漏掉它的 twin,而这对每一个正确性门都不可见;**这一轮拿不到它**,要拿必须先给那个工具加测量。对冲仍在:`ResourceDestroy` 是从 `~BufferObject` **无条件**发射的、不是靠清扫,顺序(先发射、后 `MGPipeSlots().Free`)由 `HandleRecycleScenario` 的三个臂把关(§16、§17)。
---
## 22. P4a 五部分门(`6035c9d7` 全量 + `8c458cd5` 复跑,基线 `37da3c3a`
P4a 的代码头是 **`8c458cd5`**;下面的"全量"一列跑在 `6035c9d7`(终审修复轮之前的那个头,`wsl_p4a_gate.sh` 完整五部分含三次 retrace),"复跑"一列是修复轮落地后在 `8c458cd5` 上重跑的同一组。两次之间只差终审那五个提交,门的口径没变。
| 门 | `6035c9d7`(全量) | `8c458cd5`(复跑) |
|---|---|---|
| **G1** pull 符号(认定 resize 集为空) | 0 增 / 0 删 / 0 重命名 / 0 resize`.text` 字节不变 | 同上 |
| **G5** 字节一致区 | P3a 十一函数 rc 0;P4a 自己 **17 区 / 3 文件** rc 0self-test **8 个阴性对照全部按名变红** | 同上 |
| **G2** pull vs push 测试名 | 差 0 | 差 0**2902** 条) |
| **G14** 测试名增删 | 0 删除 / +275 | 0 删除 / **+314** |
| 单元 | 1772 × 3linux / push / verify | **1785 × 3** |
| `integration-gpu` | **1091/1091 × 七臂**(默认 `0x1fff``0x1ff``0``0x7f`、pull、`ESPRYT_DISABLE_INVALIDATE_FLUSH=1`、族正则 497/497 | **1117/1117 × 七臂**(多了 `0x9ff``0x5ff` 两个依赖拒绝臂;DirectVulkan **559/559** |
| `integration-verify` | **896/896,零 `Fatal{`** | **920/920,零 `Fatal{`** |
| retrace79 例) | verify 臂 **79/79 全 armed、零分歧、零 Fatal**push 臂 **79/79**G3b 具名 **12/12** | push 臂 **79/79** |
| 三个阴性对照脚本 | 全部 rc 0:`g7_negative_control.sh``p3a_vertex_input_negative_control.sh` 点名 `IsBgra`、**`p4a_descriptor_negative_control.sh` 点名 `Layered``borderColorForm`** | 同上 |
| 子系统对照套件 | `CsoContentAddressing` + `ResourceSubsystemControl` + `ObjectSubsystemControl` **24/24**`0x9ff` 依赖拒绝臂 **14/14**`HandleRecycle`verify**180/180** | 同上 + `184/184` controls |
| 八族拒绝普查 | — | **0**16 条 needle × retrace 日志、13 行 × itest 日志) |
**两处口径,都是这一波踩出来的**
1. **`ctest -V` 做拒绝普查是假零。** console sink 在发布配置里被编译掉,`ctest -V` 抓不到任何 `MGLOG_E`;必须逐用例单跑并读它自己的日志文件(工具 `~/w7/notes/tools/wsl_p4a_refusal_census.sh`)。一份"零拒绝"的普查如果是用 `-V` 取的,它证明的只是 sink 被关了。
2. **普查的短语表必须覆盖全部八族**,而且要小心跨字符串字面量换行的句子——最初那版漏了 sampler 与 renderbuffer 两族,正好是后来真出问题的那两族。
## 23. 这一波真正的产出:缝的分类
P4a 的契约改了七次(`c0b``c0g`),外加一轮缝类审计与一轮终审修复。把它们按**类**记下来,比按包记有用得多——每一类都在多个包里重复出现过,而下一阶段的 brief 应当在开工前就把这几张表写死:
| 类 | 这一波的实例 | 症状 | 预防 |
|---|---|---|---|
| **编码没定死** | `MGPSubData::Target`(低字节资源目标 + 高字节上传目标 vs 裸枚举)、`DepthStencilMode``MGPSurface::Kind`、缺 `TextureTarget` | 两侧各自发明一套;`TextureUploadTarget::Texture1D == 0``kMGPipeResourceTargetBuffer == 0` 撞上,applier 的"这是 buffer 吗"判定被静默污染 | **编码表**:每个字段一行,写清位布局与零值含义,放进契约而不是包头 |
| **身份 vs 内容** | 内置 samplerclient 按对象身份铸、cache 按内容铸;Espryt twin 按身份查那条内容寻址的记录 | 查找永远落空 → 整族拒绝(本波两次,其中一次让 17 条 Iris 光影 trace 全部用驱动默认采样器) | **每 kind 两侧 handle 规则表**:谁铸、按什么键、谁查、按什么键 |
| **记录键错了维度** | framebuffer 记录按"当前绑定"存,DSA 的 `BlitNamedFramebuffer` / `ClearNamedFramebuffer*` 按名字来 | 打进一个从没收到附件的 FBO;SSIM 看得见但没有任何拒绝 | 记录按**对象**存,绑定另存句柄;第四个 target 值 `Named` |
| **进程级单例 vs 每上下文命名** | `CompositeResolver` 按管线 GL 名记忆,GL 名是每上下文的 | 一次 make-current 就释放掉另一个上下文还活着的合成体 | 单例的键必须含上下文身份 |
| **破坏性客户端动作缺前置条件** | 按 acceptance 清 dirty,但 (a) Magma 根本没有消费者,(b) D-K2 依赖位只在服务端拒 | 上传丢失:66 条 DirectVulkan 用例、`0x7ff` 下 438/491 | **消费者门 + 依赖门都要在客户端侧**:没消费者/依赖不满足时**一条不发**,而不是发了再在服务端拒 |
| **快门看不见自己的主体** | `glBindSampler` 只动位 12 的世代;SSO 下 `GetCurrentProgram()` 恒 null`glBindImageTexture` 只换 level 时三个计数器都不动 | 记录停在上一次的值,第一个真读该字段的消费者画错(`create-indirect` ssim 0.887 | **"记录字段 → setter → 快门"完备表**;新增计数器会撑大 pull 对象、G1 不允许,所以优先混入已有世代 |
| **清得太宽** | `resource_respecify` 清掉整张待上传表 | 已被接受、客户端标志已清的那一级永久丢失(读回全黑) | 作用域随调用走:一级 / 截断链 / 整资源 |
| **死亡没通知发射方** | 六个死亡 helper 只释放 slot | 已删但未复用的句柄仍解析到已释放的前端对象 → 下一个 validate 点对已释放内存调虚函数 | 死亡在 wire delete 与 free 之间转发给每个 emitter;查找按"活着"判定而不只按世代 |
| **门不能变红** | `G7` 脚本因 scoped enum 写 0 而永远编译不过、`HighWater(ShaderCso)` 取的是段顶、G9 的红前态公共 GL 不可见 | 绿得毫无意义 | 每个门都要有阴性对照并**真跑过一次红**;公共 GL 看不见的,改白盒断言(`PipeApplyPeek` |
**一个方法论上的结论**:本波六个包的 v1 全部通过了自己的门,**六份对抗性复审全部判 REWORK**,而其中最贵的两个缺陷(丢上传、delete 后 UAF)是**整体 diff 终审**才抓到的——因为它们跨包:发射方、applier、twin 各自都自洽。所以"每包一审 + 集成后整体终审"这条流程里,**终审不是形式**,它是唯一能看见跨包契约的那一轮。
## 24. P4a 设备配对 A/B(三臂)、MC 26.3 的 p99、上传形状(记录项)
**先说口径,再看数(`MEASUREMENTS.md:440` 那条告诫在 P4a 上再次成立)**:`acc/draw` 数的是**约十个热入口上的静态计数点**,所以"把读点搬走"和"把工作去掉"在它上面长得一模一样。P4a 恰好是**搬**的一波:79 例语料上 DirectGLES 的 `acc/draw` 普遍下降(26.3 `10.49 → 8.34``rei-inventory` `13.33 → 11.25``rd12` `8.09 → 6.09`),而同一批运行的逐线程 CPU 是**上升**的。**这不是矛盾,是这个计数器的定义**:后端不再每 draw 去 `pGLContext` 上取,改成读被推送的记录,站点自然少计——工作搬到了客户端的发射侧。要判性能只看 CPU 时间序列与门的命中/未命中对,`acc/draw` 只能与站点常量表一起读。
**设备与协议(与 §20 的两台机不同,这里换了机器)**:红米 M332BF(`2f7cbe2e`SM8750 / **Adreno 830v2**,与 §20 的小米同 SoC 同定频点,数值可比)。reboot-clean → 大核 `policy6` 钉 1958400、小核 `policy0` 钉 1555200、GPU `pwrlevel 0`;**这台的 GPU 有效定频是 1050 MHz 不是 1100**——厂商把 `kgsl-3d0/thermal_pwrlevel` 常驻 1root 写 0 无效(33 °C + 风扇全速下验证),`pin_device.sh` 按 1050 判定。全程**主动风扇恒定 level 2~14.5k rpm)**:它对两臂是同一个常量,作用是把每用例之间的降温从 20–30 分钟压到 1 分钟以内,**40 个样本 40 个 `pin check` 全是 PINNED**(§20 那轮有 22 个样本因热漂移作废重跑)。APK 是 `8c458cd5` 的 Release trace 双臂(pull 8504077 B / push 8626957 B)。
**三臂表**`--benchmark-no-finish`,尾 200 帧、best-of-3、逐线程 CPU p50 ms`0x1ff` = P2+P3a 边界,`0x1fff` = P4a 默认):
| 用例 | 后端 | pull | `0x1ff` | `0x1fff` | Δ P2+P3a | Δ 合计 | **P4a 自己** |
|---|---|---|---|---|---|---|---|
| improved-transparency-26.3 | Espryt | 10.716 | 11.754 | 12.124 | +9.7% | +13.1% | **+3.4 pt / +0.37 ms** |
| improved-transparency-26.3 | Magma | 10.603 | 11.543 | 11.585 | +8.9% | +9.3% | +0.4 pt(噪声) |
| rd12-odinlite | Espryt | 8.210 | 10.798 | 11.182 | +31.5% | +36.2% | **+4.7 pt / +0.38 ms** |
| rd12-odinlite | Magma | — | — | — | — | — | 三臂全 `rc=1`,见下 |
| fabric-sodium | Espryt | 1.312 | 1.406 | 1.454 | +7.2% | +10.8% | +3.6 pt / +0.05 ms |
| fabric-sodium | Magma | 0.474 | 0.502 | 0.507 | +5.9% | +7.0% | +1.1 pt(噪声) |
| 1.21.4-in-world | Espryt | 2.369 | 2.732 | 2.867 | +15.3% | +21.0% | **+5.7 pt / +0.14 ms** |
| 1.21.4-in-world | Magma | 1.028 | 1.147 | 1.145 | +11.6% | +11.4% | 0.2 pt(噪声) |
| fabric-iris-bsl | Espryt | 1.727 | 1.740 | 1.811 | +0.8% | +4.9% | +4.1 pt / +0.08 ms |
| fabric-iris-bsl | Magma | 0.742 | 0.788 | 0.786 | +6.2% | +5.9% | 0.3 pt(噪声) |
**读法。** (1) **P4a 自己在 Espryt 上是 +3.4 +5.7 个百分点**(绝对值 0.05–0.38 ms/帧),大头仍然是 P2+P3a 那条边界——rd12 上 36.2% 里有 31.5% 是它。(2) **Magma 的 `0x1ff` 与 `0x1fff` 两臂在四个用例上逐个落在噪声内**+1.1 / +0.4 / 0.2 / 0.3 pt),这是 c0f 那道"没有后端注册 `MGPipeResourceOps` 就一条不发"的门在设备上的读数——Magma 仍然要付 P2+P3a 的客户端发射(它消费那些族),但 P4a 的四族对它完全免费。(3) **`vanilla`1.21.4-in-world)是 Espryt 上 P4a 占比最高的用例**+5.7 pt),它 draw 少、状态切换密,正是句柄化最不划算的形状;`sodium`/`iris-bsl` 这种把状态压平的语料几乎不受影响。
**头条一:MC 26.3 在 Adreno 上的 p99。** pull **25.297** → P4a **26.841 ms+6.1%**`0x1ff` 臂 26.387Magma 侧 24.979 → 26.021。对照 §20 的 P3a 读数(25.457 → 26.322+3.4%)与 `MEASUREMENTS.md:87` 的采纳基线(p99 163 → 21 ms),**仍在 21–26 ms 档内、没有回到采纳前的形态**——按口径记录,不判门。
**头条二:GUI/atlas 的纹理上传形状,pull 与 push 逐项相同。** 79 例语料两臂各跑一遍带 `MOBILEGL_PIPE_STATS=1` 的 retrace`8c458cd5`,客户端计数器 `tex[emit/box/rect/jobs]`):
| | emit | box | rect | jobs |
|---|---|---|---|---|
| pull | 18451 | 16060 | 2391 | 39926 |
| push | 18453 | 16062 | 2391 | 39928 |
| 差 | **+2** | **+2** | **0** | **+2** |
**整份语料上唯一的形状差异是 2 次**,而且正是那 2 次 `trp`(纹理重铸拉取,见 `ROADMAP.md` 开放问题 2)带来的重放上传——即"盒 vs 矩形"的分解一格没动。这是 SSIM 看不见、Mali 那道 ~+6 ms/帧的悬崖就藏在里面的那个数(`ARCHITECTURE.md` §6),P4a 在这里是**中性**的。逐用例看也一致:`rei-inventory` Espryt 两臂都是 16/16/0/16Magma 两臂都是 47/46/1/75。
**一条留给优化阶段的线索(不是缺陷,是读数)**:设备上 26.3 Espryt 的 `sve`(真正发出去的 sampler-view 集合数)**≈ draw 数**9143 次 / 9138 draw,每窗口 120 帧),而桌面同一 fixture 只有 ~0.07/draw。`PipeStats.h` 给这四个集合计数器写的用途正是这个——"抑制器不再抑制时,它的计数会跟着 draw 数走而不是跟着状态变化走"。桌面与设备的差异说明这跟负载形状有关而不是无条件失效,但 **26.3 在设备上每 draw 重发一次 sampler-view 集合**是 Espryt 侧 P4a 那 +0.37 ms 最值得先查的去处,列进 P3b/P4b 的优化清单。
**`rd12` + Magma 在这台机上照样崩**:三臂(含 pull)全部 `rc=1`,与 §20 在小米上的记录一致(`scudo::reportMapError``remapImpl``scudo_calloc``libMobileGL.so`)。**换了一台同 SoC 的机器仍然复现,进一步确认它是 `dev` 侧的问题而不是设备个例**;按 `ROADMAP.md:7` 的纪律不在本分支顺手修,处置沿用 §20:排除在 A/B 之外、留在桌面语料里(桌面两臂均通过)。
## 25. P4a DriverBenchT1 / T2 / T3(桌面,lavapipe + llvmpipe,记录项)
`wsl_p4a_bench.sh``8c458cd5` 上重跑(原始表 `~/w7/notes/p4a/bench/driverbench.{csv,md}`repeats=5 / frames=240,空闲机)。臂:`pull``push``0x1fff`)、**`push7f` 一列在 P4a 里装的是 `0x1ff`**P2+P3a 边界 = 设备侧那个 T2 的桌面对应物)、`push0``PIPE_PUSH=0`)、`nocso`(关 CSO 内容寻址的负面对照)。
| 臂 | `mc_vanilla_draw` | `mc_state_toggle` | `mc_pass_switch` |
|---|---|---|---|
| native | 4398.9 | 21387.3 | 412657.8 |
| espryt-pull | 4684.4 | 22010.4 | 410936.2 |
| espryt-push`0x1fff` | 5760.5 | 22974.9 | 419703.1 |
| espryt-`0x1ff` | 5749.1 | 23768.2 | 419288.0 |
| espryt-push0 | 5355.2 | 23618.8 | 420362.2 |
| espryt-nocso | 5929.0 | 23685.0 | 418677.0 |
| magma-pull | 15394.9 | 31678.5 | 420197.6 |
| magma-push`0x1fff` | 16023.9 | 32417.4 | 415641.9 |
| magma-`0x1ff` | 15847.5 | 32100.3 | 416670.4 |
| magma-push0 | 15946.6 | 31610.2 | 417410.5 |
| magma-nocso | 15846.2 | 32495.9 | 411306.4 |
`mc_vanilla_draw` 上:**espryt T1`0x1fff` pull= +1076.1 ns/drawT2`0x1ff` pull= +1064.7T1 T2 = +11.4**magma T1 = +629.0、T2 = +452.6、T1 T2 = +176.4。blend toggleespryt +964.5 / magma +738.9 ns per toggle pairpass switchespryt +8766.9、magma 4555.7(后者符号为负,属该项的噪声量级)。
**桌面这台机上,"P4a 自己"落在本 bench 的噪声底以下,所以不要单独引用它。** 同一份脚本在 `6035c9d7`(终审修复前)上跑出的是 espryt T1 +1269.7 / T2 +1135.8 / **T1 T2 = +133.9**,本轮是 +1076.1 / +1064.7 / **+11.4**——**两臂的绝对值在两轮之间各自漂了 ~200 ns,而它们的差只有 10130 ns**,也就是说这个 bench 分辨不出 P4a 这一档的增量。真正可引用的是:(1) **T1 ≈ +1.1 µs/draw 的总边界**(对 pull 基线,Espryt;这条在两轮之间是稳的);(2) **设备侧的三臂表(§24**——那里 P4a 自己是 +3.4 +5.7 个百分点、0.05–0.38 ms/帧,样本全部在验证过的定频窗口里。**Magma 的 T1 T2 = +176.4 ns 不是"Magma 在跑 P4a"**c0f 的消费者门让它一条 P4a 记录都不发(设备侧 §24 的 Magma 两臂差也在噪声内),这 176 ns 是 tracker 多算的那几个快门加噪声。
+2 -2
View File
@@ -1,6 +1,6 @@
# MGPipeMobileGL 前后端拆分
> 状态:**P0、P0.5、P1、P2、P3a 已落地**`feat/disaggregated@fde5fda3`,基线 `dev@9eae9858`)。第 43 天 GO/NO-GO 判定为**继续**。P3ahandle wave 1Espryt 的 buffer 与 VAO)已交付,**下一步 P4a**handle wave 2FBO / 纹理 / sampler / program 的身份与描述符)。见 `ROADMAP.md`
> 状态:**P0、P0.5、P1、P2、P3a、P4a 已落地**`feat/disaggregated@8c458cd5`,基线 `dev@9eae9858`)。第 43 天 GO/NO-GO 判定为**继续**。P4ahandle wave 2Espryt 的 FBO / 纹理 / sampler / program 身份与描述符)已交付,Espryt 的对象类读点至此全部走句柄;**下一步 P3b/P4b**(深化:memo 重键、发射游标、raw-depth-fetch sampler 原生化)与 **P5**(传输 + inproc applier,也只依赖 P4a)。见 `ROADMAP.md`
>
> 性能纪律(2026-09-08 起):逐线程 CPU 与 tracker 绝对 ns **对着 pull 臂基线记录**,不再作阻塞门(push 比 pull 多约 10% 逐线程 CPU 已被接受;该读数出自 -O0 APK,Release 基准线见 `MEASUREMENTS.md` §20),专门的优化阶段排在路线图推完之后。
@@ -31,7 +31,7 @@ MGPipe 是 MobileGL 前端(`MG_State` + `MG_Impl`)与后端(`MG_Backend`
|---|---|
| `ARCHITECTURE.md` | 已定稿的设计与架构:句柄与世代、调用目录、记录约定、tracker、纹理路径、shader 制品、反向通道、后端改造、传输、persistent map 分档、进程/EGL/平台、构建与纯度门、验证策略 |
| `ROADMAP.md` | P0…P13 阶段表、两条跑道、GO/NO-GO 清单、再基线检查点、仍然开放的问题 |
| `MEASUREMENTS.md` | 逐阶段实测:P0(spike A/B、双设备边界计数器基线、桌面数据点、语料事实)、P1(verify harness 门)、P2(五部分门、两机配对 A/B、DriverBench T1/T2、计数器)、P3a(门、接缝缺陷、Track H 普查、两机 A/B)与复现命令 |
| `MEASUREMENTS.md` | 逐阶段实测:P0(spike A/B、双设备边界计数器基线、桌面数据点、语料事实)、P1(verify harness 门)、P2(五部分门、两机配对 A/B、DriverBench T1/T2、计数器)、P3a(门、接缝缺陷、Track H 普查、两机 A/B)、P4a(门、契约七次修正与两轮终审修复、缝类审计、三臂设备 A/B、DriverBench T1/T2/T3与复现命令 |
代码地图(P0 已落地的部分):
File diff suppressed because one or more lines are too long
+90
View File
@@ -487,6 +487,30 @@ def check_call_payloads_have_field_lists(calls, payloads):
"would be blind to them: %s" % ", ".join(missing))
# The MGPipeCallFlags enumerators, MGPipe.h:39-54. Kept here rather than parsed out of the
# header because this list is what the generated kMGPipeCallFlags[] table spells into C++:
# a flag token in PipeCalls.def that is not one of these would generate an expression that
# does not compile, and a build break several minutes later is a worse diagnosis than this
# one line. kNone is listed but is NOT a flag - it is the empty set, and it may not be
# combined with anything.
KNOWN_CALL_FLAGS = ("kNeedsAck", "kHasBlob", "kVarTail", "kHostSpan", "kReplySlot", "kOptional")
def check_call_flags_are_known(calls):
"""Every flag token in PipeCalls.def must be an MGPipeCallFlags enumerator, and kNone may
not be combined with one. Runs in both modes, --check included: P5 R-13.4 exports these
flags as a table six packages read, so a typo here is a wrong decode rather than a
compile error in the one consumer that used to hard-code its own copy."""
for call in calls:
for flag in call.Flags:
if flag != "kNone" and flag not in KNOWN_CALL_FLAGS:
sys.exit("PipeCalls.def: %s carries flag %s, which is not an MGPipeCallFlags "
"enumerator (%s)" % (call.Name, flag, ", ".join(KNOWN_CALL_FLAGS)))
if "kNone" in call.Flags and len(call.Flags) != 1:
sys.exit("PipeCalls.def: %s combines kNone with %s; kNone is the empty set"
% (call.Name, "|".join(f for f in call.Flags if f != "kNone")))
def parse_coverage():
text = read(os.path.join(PIPE_DIR, "Coverage.def"))
accessors = []
@@ -641,6 +665,59 @@ enum class MGPWireOp : Uint16 {
out.append(" %s = %d," % (call.Name, call.Index))
out.append(" kOpCount = %d," % (len(calls) + 1))
out.append("};\n")
out.append("""// 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.""")
out.append("inline constexpr Uint32 kMGPipeCallFlags[static_cast<SizeT>(MGPWireOp::kOpCount)] = {")
out.append(" /* 0 %-24s*/ static_cast<Uint32>(kNone)," % "kInvalid")
for call in calls:
out.append(" /* %2d %-24s*/ static_cast<Uint32>(%s),"
% (call.Index, call.Name, " | ".join(call.Flags)))
out.append("};")
out.append("static_assert(sizeof(kMGPipeCallFlags) / sizeof(kMGPipeCallFlags[0]) ==")
out.append(" static_cast<SizeT>(MGPWireOp::kOpCount),")
out.append(" \"the flags table and the opcode space disagree\");")
out.append("""
// 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");
""")
for call in calls:
out.append("struct alignas(8) MGPWireRec_%s {" % call.Name)
out.append(" MGPWireRecHeader Header;")
@@ -1232,12 +1309,24 @@ def self_test(accessors):
calls_for_control = parse_calls()
controls.append(("emitted row naming a call that does not exist", lambda: gen_emitted_by(
[("GetViewport", "SetDynamicState")], calls_for_control, [("GetViewport", "NotACall")])))
# P5 R-13.4's gate. The flags are now a GENERATED TABLE six packages read instead of six
# hard-coded copies, so a token that is not an MGPipeCallFlags enumerator has to stop the
# generator rather than emit an expression that fails to compile minutes later - and
# kNone, the empty set, may not be OR'd with a real flag and quietly read as one.
flag_typo = Call(1, "Canned", "MGPHandleOnly", "kScreen", ["kHasBlobb"])
flag_kNone = Call(1, "Canned", "MGPHandleOnly", "kScreen", ["kNone", "kHasBlob"])
controls.append(("call flag that is not an MGPipeCallFlags enumerator",
lambda: check_call_flags_are_known([flag_typo])))
controls.append(("kNone combined with a real flag",
lambda: check_call_flags_are_known([flag_kNone])))
trips = 0
for name, fn in controls:
trips += expect_trip(name, fn)
# The positive control: the canned struct's exact list passes, and the parser sees the
# padding member as padding and the function as not a member.
check_field_lists_cover_struct_members({"Canned": ["A", "B", "C"]}, ["Canned"], [canned_struct])
# ... and the real catalogue's real flags pass the same gate.
check_call_flags_are_known(calls_for_control)
if trips == 0:
sys.exit("gen_pipe: self-test: no negative control tripped - the gates are not checking anything")
if trips != len(controls):
@@ -1256,6 +1345,7 @@ def main():
calls = parse_calls()
payloads = parse_verify_payloads()
check_call_flags_are_known(calls)
check_call_payloads_have_field_lists(calls, payloads)
check_field_lists_cover_struct_members(parse_field_lists(), payloads)
accessors, deltas, sticky, emitted = parse_coverage()
+14 -10
View File
@@ -1811,17 +1811,21 @@ def self_test(scanned, bits, publishers, movers, moved, outside=None, undecided_
"20%s (the %s row is STALE the moment the scan stops finding it)"
% ("abcd"[index], name))
# 21. THE TWO UNDECIDED MARKS ARE STILL LOAD-BEARING. Dropping them has to make --check
# refuse both rows as unmarked UNDECIDED - which is what says the marks are covering a
# real blind spot rather than a verdict the analysis could give today. Control 18 is
# the other direction: a mark the derivation DOES decide is itself a problem, so
# neither of these can outlive its reason.
# 21. EVERY UNDECIDED MARK IS STILL LOAD-BEARING. Dropping them all has to make --check
# refuse EVERY marked (mutator, bit) as an unmarked UNDECIDED, and nothing else - which
# is what says each mark is covering a real blind spot rather than a verdict the
# analysis could give today. Control 18 is the other direction: a mark the derivation
# DOES decide is itself a problem, so no mark can outlive its reason. Read from the
# file's own list rather than spelled here, so a row that gains a bit - UseProgram
# gained NEW_SAMPLER_VIEWS and NEW_SHADER_IMAGES at the P4a fable seam round - cannot
# silently turn this control into one that counts the wrong number.
problems, _, _, undecided_rows = object_class_problems(real, bits, movers, moved, outside, {})
tripped(any(p.startswith("UNDECIDED answer NEW_SHADER for UseProgram") for p in problems)
and any(p.startswith("UNDECIDED answer NEW_VERTEX_ELEMENTS for BindVertexArray")
for p in problems)
and len(undecided_rows) == 2,
"21 (the two P4a undecided marks are still needed)")
marked_pairs = sorted((mutator, bit) for mutator, marks in real_marks.items() for bit in marks)
tripped(marked_pairs
and all(any(p.startswith("UNDECIDED answer %s for %s" % (bit, mutator)) for p in problems)
for mutator, bit in marked_pairs)
and len(undecided_rows) == len(marked_pairs),
"21 (every P4a undecided mark - %d of them - is still needed)" % len(marked_pairs))
# THE POSITIVE CONTROLS. (a) The row that was wrong in round 3: SetPixelStoreParam writes
# NEW_PIXEL_PACK's shutter member sixteen times, through a token-pasting macro; it has
+520
View File
@@ -0,0 +1,520 @@
#!/usr/bin/env bash
# G7's negative controls for P4a: drop one field from each of two descriptor conversions on purpose
# and prove the emission-consistency suites say so, NAMING the field.
#
# WHAT G6 CLAIMS. For every framebuffer configuration, texture object, sampler object and program
# the client's emitted MGPFramebufferState / MGPResourceDesc / MGPTextureParams / MGPSamplerDesc /
# MGPSamplerView / MGPProgramDesc reproduce EXACTLY the values Espryt's SyncToBackend family reads
# from the frontend today, field by field. MG_Test/Pipe/{FramebufferEmit,TextureEmit,SamplerEmit,
# ImageEmit,ProgramEmit}Test.cpp are what walk it.
#
# WHY A CONTROL IS NEEDED AT ALL. Those suites are green on a correct conversion, and they would be
# just as green on a conversion they had stopped looking at: a walk that drove no configurations, a
# comparison that stopped reading the record, an assertion someone loosened. Green says nothing
# about whether a suite can still fail. This script makes each of them fail, for the one reason it
# exists to catch, and reports a NON-ZERO ctest THAT NAMES THE DROPPED FIELD as the pass.
#
# THE TWO BREAKS, one per new conversion family, each a break the COMPILER CANNOT SEE - the struct
# still has the member, the record is still its pinned size, the PipeFields.def row still names it
# and the generated comparator still compares it. What breaks is the VALUE:
#
# 1. MGPSurface::Layered stops being copied in MG_Impl/Pipe/FramebufferEmit.h. A layered
# attachment travels as a non-layered one, which is the shape that turns a whole-array render
# target into slice 0 - and SupportsLayeredImageBinding's rule (DirectGLES.cpp:1992-2013,
# D-O) forces `layer` to 0 for a non-layerable target, so the wrong answer is silently
# plausible rather than an error. It is also the field with the least other coverage: the
# surface's Res, Level and Layer are all still right, so nothing about the ATTACHMENT changes
# except the one bit that says how much of the texture it is.
# TWO MECHANISMS, NOT ONE, on a tree that carries the framebuffer emitter (review F-m8): the
# regex also matches the field's copy inside the ContentHash staging helper, so the run drops
# Layered from the emitted record AND from the framebuffer content hash. The suite still goes
# red naming the field, which is what the control asks; the sentence above is narrowed here
# rather than in the code because excluding the hash copy would mean hard-coding another
# package's helper name into a regex that deliberately does not know one.
# 2. SamplerParameters::borderColorForm stops being copied in MG_Impl/Pipe/SamplerEmit.h. All
# four border-colour VALUES still cross; what is lost is which of the three forms
# (float / int / uint) they are to be read as, and D-F4 is explicit that the form crosses and
# that all four values are compared. The failure it models is IntegerBorderColorScenario's:
# an integer border colour read as floats is not an error anywhere, it is just the wrong
# colour at the clamped edge of every sampled texture.
#
# Each patch is applied by REGEX rather than by an exact line, because both headers belong to OTHER
# PACKAGES (C.7: FramebufferEmit.h is package B's, SamplerEmit.h is package C's) and their spelling
# is theirs to choose: any `<something>.<field> = <expr>;` - or the designated-initializer
# `.<field> = <expr>,` - has its right-hand side replaced by a constant. If a header does not
# assign its field at all, because the emitter has not landed yet or because the conversion is
# spelled some other way, that is exit 2, "could not run", never a pass.
#
# THE CONSTANT IS `{}` AND NOT `0`, and the one character is the difference between a control that
# can answer and one that cannot (review F-M1). SamplerParameters::borderColorForm is a SCOPED enum
# (MG_Pipe/MGPipeValueTypes.h, `enum class BorderColorForm : Uint8`), and `x = 0` on one is
# `cannot convert 'int' to 'BorderColorForm' in assignment` - the patched header does not compile,
# the script takes its "the patched header did not compile" path, and the control reports
# could-not-run FOREVER, on every tree, with a summary line saying that exit 2 is the expected
# answer here. `x = {}` is valid for the scoped enum AND for MGPSurface::Layered (a Uint8), so ONE
# replacement covers both controls and neither of them needs to know its field's type.
#
# A CONTROL'S FIELD MUST MATTER IN AT LEAST ONE CASE. `{}` is the type's zero, so a suite whose
# every case happens to expect the zero value of the field would stay green with the copy dropped -
# which this script would then report, correctly, as "the negative control did not trip". Both
# suites' headers say they must fail BY FIELD NAME, and both drive a non-default value; that is the
# owning package's contract, and this script is what checks it rather than assuming it.
#
# ON THE P4a CONTRACT TREE THIS SCRIPT EXITS 2 AND SAYS SO. Both headers EXIST there - the contract
# commit creates all five emit headers with STUB emitters that return 0 payload bytes (contract-v1
# D1) - but neither assigns anything, so there is no field copy to drop. That is the honest report:
# a control that "passed" because there was nothing to break would be the worst outcome available.
#
# WHY IT IS NOT A CI LANE. It rebuilds the library up to four times. It is run by hand, and by the
# integrator at the P4a five-part gate (D.3 part 3, beside P2's g7_negative_control.sh and P3a's
# p3a_vertex_input_negative_control.sh, both of which keep running unchanged).
#
# Usage:
# scripts/p4a_descriptor_negative_control.sh <build-dir>
#
# <build-dir> a configured build directory carrying the push-only unit suites (the emission
# cases are compiled only under MOBILEGL_PIPE_PUSH). It is CHECKED, not assumed:
# in a pull build every emission case is a visible skip, ctest is green before and
# after the patch, and the run would record "the negative control did not trip" -
# a finding about the suite that is really a finding about the build directory
# (review F-m10). MOBILEGL_PIPE_PUSH is read out of the directory's CMakeCache.txt.
#
# RESTORE IS NOT ENOUGH; THE REBUILD IS PART OF THE CONTRACT. Once a header has been patched, EVERY
# way out of this script goes through repair(): restore the header, rebuild the library from it, and
# re-run the suite to prove the tree really went back. `cp` alone leaves <build-dir> holding a
# libMobileGL.so in which the field is hard-wired, `ctest` does not rebuild, and nothing in an exit
# status tells a caller to. The path that needs this most is the one that reads "NEGATIVE CONTROL
# DID NOT TRIP": an engineer reacts to it by opening the suite and re-running ctest against this
# very build directory, and every reading they take there would come from a deliberately corrupted
# library. So repair() runs from the EXIT trap as well - a mid-way failure (a patched header that
# would not compile, an interrupt) repairs too - and a repair that itself fails downgrades the
# verdict to 2. That downgrade is delivered from THREE places, and REPAIR_RC is what makes the
# last two of them possible (review F-v2-m4, which found it set and never read): run_control's
# own `if ! repair` turns a failed repair into 'could-not-run' for that control; the check after
# the control loop catches a repair that failed on any earlier path and forces the run's exit
# code to 2; and the EXIT trap reads it too, so a repair that fails while the script is on its
# way out through an early `exit` cannot leave a corrupted build directory behind a 0 or a 1. The state repair() reads lives in THIS shell and not in a command substitution,
# which is what makes the trap version of it a real repair rather than a no-op, and every child's
# exit status is additionally checked for "killed by a signal" so that a Ctrl-C stops the run
# instead of carrying on into the next control and signing off with a verdict about a control
# nobody ran. On the exits that happen BEFORE any patch (bad arguments, a missing header, a
# missing suite) the tree was never touched and the build directory still holds what the caller
# built: there is nothing to restore and nothing to rebuild, and the script says which of the two
# situations it is leaving behind.
#
# Nothing is written into the repository. Logs and the header backups live in
# <build-dir>/p4a-g7-logs/, which is inside the build tree and therefore neither committed nor
# picked up by `git status`; the paths are printed with every verdict.
#
# Exit codes: 0 BOTH controls tripped AND named their field;
# 1 a control did not answer: a suite stayed green with its field dropped, or it went
# red without ever naming the field, so the red cannot be attributed to the drop.
# Both are findings about the TEST, not errors in this script - and both leave the
# tree restored AND rebuilt AND re-run;
# 2 a control could not be run at all (bad arguments; a build directory that is not a
# push one; a header or a field absent on this tree; no matching test; a build that
# was already broken; a patched header that did not compile; a failed restore or a
# failed rebuild after one; the run was INTERRUPTED). Exit 2 wins over exit 1:
# "could not run" is never reported as "did not answer".
set -u -o pipefail
BUILD_DIR=""
while [ $# -gt 0 ]; do
case "$1" in
-*) echo "unknown arg: $1" >&2; exit 2 ;;
*) BUILD_DIR=$1; shift ;;
esac
done
[ -n "$BUILD_DIR" ] || { echo "usage: $0 <build-dir>" >&2; exit 2; }
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) || exit 2
cd "$REPO_ROOT" || exit 2
[ -f "$BUILD_DIR/CMakeCache.txt" ] || { echo "$BUILD_DIR is not a configured build directory" >&2; exit 2; }
# F-m10: a PULL build directory would take both controls through "did-not-trip", which reads as a
# finding about the suites and is a finding about the argument. Every emission case is
# `#if MOBILEGL_PIPE_PUSH` and a visible skip otherwise, so ctest is green before the patch and
# green after it, and the script would report a defect in somebody else's test suite.
#
# BOTH CACHE ENTRIES ARE READ, because MOBILEGL_PIPE_VERIFY=ON forces MOBILEGL_PIPE_PUSH on for
# the configure WITHOUT writing it back to the cache (CMakeLists.txt: the `set(... ON)` shadows the
# cached OFF). A verify build directory therefore compiles the emission cases while its cache still
# says MOBILEGL_PIPE_PUSH:BOOL=OFF, and refusing it would be exactly the wrong answer.
mgl_cache_is_on() {
local line
line=$(grep -m1 "^$1:" "$BUILD_DIR/CMakeCache.txt" 2>/dev/null) || return 1
case "${line#*=}" in
ON|On|on|1|TRUE|True|true|YES|Yes|yes|Y|y) return 0 ;;
*) return 1 ;;
esac
}
if ! mgl_cache_is_on MOBILEGL_PIPE_PUSH && ! mgl_cache_is_on MOBILEGL_PIPE_VERIFY; then
echo "$BUILD_DIR does not carry the push-only emission cases:" >&2
grep -E '^MOBILEGL_PIPE_(PUSH|VERIFY):' "$BUILD_DIR/CMakeCache.txt" >&2 || \
echo " (its CMakeCache.txt names neither MOBILEGL_PIPE_PUSH nor MOBILEGL_PIPE_VERIFY)" >&2
echo "Every case in MG_Test/Pipe/{Framebuffer,Sampler}EmitTest.cpp is #if MOBILEGL_PIPE_PUSH and" >&2
echo "a visible skip otherwise, so here ctest is green with the field dropped as well as without" >&2
echo "it and this script would report 'the negative control did not trip' about a suite that" >&2
echo "never ran - a finding about the build directory dressed up as a finding about the test." >&2
echo "Point it at the push (or verify) build directory." >&2
exit 2
fi
# One row per control: <header>@<field>@<ctest regex>@<owning package>. The ctest regex is the
# SHORTEST string that selects only that suite, the way the CI filters are written.
CONTROLS="\
MobileGL/MG_Impl/Pipe/FramebufferEmit.h@Layered@FramebufferEmit\.@B (clientfb)
MobileGL/MG_Impl/Pipe/SamplerEmit.h@borderColorForm@SamplerEmit\.@C (clientsp)"
# Inside the build tree, never in the repository: a run must not leave untracked files behind, and
# .gitignore carries no rule for a p4a-*.log at the root. Falls back to a temp directory only if
# the build directory cannot be written, which would be a strange build directory.
LOG_DIR=$(cd "$BUILD_DIR" && pwd)/p4a-g7-logs
mkdir -p "$LOG_DIR" 2>/dev/null || LOG_DIR=$(mktemp -d) || exit 2
say() { echo "[p4a-g7] $*" >&2; }
# --- the repair, shared by every exit path ---------------------------------------------------
# PATCHED_HEADER is the one header currently patched, empty when the tree is pristine. Only one
# control is ever in flight at a time, deliberately: two simultaneous drops would make a red
# unattributable to either.
PATCHED_HEADER=""
PATCHED_BACKUP=""
PATCHED_TEST=""
# Sticky across every repair this run performs: 0 while every repair put the tree back, 2 once
# any of them did not. Read in three places (see the header): run_control's `if ! repair`, the
# check after the control loop, and the EXIT trap. It is sticky rather than per-call because a
# build directory that was once left un-restored stays untrustworthy even if a later repair of a
# different header succeeds.
REPAIR_RC=0
repair() {
[ -n "$PATCHED_HEADER" ] || return 0
local header=$PATCHED_HEADER backup=$PATCHED_BACKUP test=$PATCHED_TEST
# Cleared FIRST, so a repair that is re-entered (the explicit call, then the EXIT trap) does the
# work once and reports the same answer twice.
PATCHED_HEADER=""
# From the byte-for-byte copy taken before the patch, never from git: someone running this on a
# dirty tree must get their own tree back, not HEAD.
if [ -f "$backup" ]; then cp -f "$backup" "$header" || { REPAIR_RC=2; return 2; }; fi
say "restored $header; rebuilding $BUILD_DIR from it"
if ! cmake --build "$BUILD_DIR" -j "$(nproc)" </dev/null > "$LOG_DIR/build-restored.log" 2>&1; then
say "the tree did NOT rebuild after the restore - see $LOG_DIR/build-restored.log"
say "THE BUILD DIRECTORY IS NOT TRUSTWORTHY: repair it before reading anything out of it."
REPAIR_RC=2
return 2
fi
if ! ctest --test-dir "$BUILD_DIR" -R "$test" --no-tests=error </dev/null \
> "$LOG_DIR/ctest-restored.log" 2>&1; then
say "the tree did NOT go back to green after the restore - see $LOG_DIR/ctest-restored.log"
REPAIR_RC=2
return 2
fi
say "the tree is restored, rebuilt and green again"
return 0
}
# INT and TERM as well as EXIT (p3a-g7 m3). A Ctrl-C during a rebuild would otherwise leave the
# patched header in the tree - bash runs no EXIT trap for an uncaught SIGINT - and the next thing
# that reader does is build, from a hard-wired field, with nothing saying so. The two extra traps
# repair and then re-raise with the default disposition, so the exit status still reports the
# signal. Armed for the whole run: before the first patch repair() is a no-op.
#
# AND THE TRAPS ARE NOT THE ONLY DETECTION, because bash's SIGINT semantics do not guarantee that
# they run. A Ctrl-C in a terminal goes to the whole PROCESS GROUP, so the `cmake` this script is
# waiting on dies first and returns 130 - and bash, having a handler installed, may go on to the
# next command rather than to the trap. Measured exactly that way: the tree WAS repaired (through
# the explicit failure path below, which is why the state has to live in this shell), but the run
# then carried on into the second control and signed off with the summary it prints on an
# untouched contract tree. Repaired and wrong is still wrong. So every child's status is checked
# for "killed by a signal" as well, INTERRUPTED latches either way, and the run stops and says so.
INTERRUPTED=""
note_interrupt() { [ -n "$INTERRUPTED" ] || INTERRUPTED=$1; }
# 128 + signal number is how a shell reports a child that died on a signal; nothing this script
# runs exits above 128 for any other reason (ctest uses 8 for failing tests).
child_was_signalled() { [ "${1:-0}" -ge 128 ]; }
trap 'note_interrupt INT; repair; trap - INT; kill -INT $$' INT
trap 'note_interrupt TERM; repair; trap - TERM; kill -TERM $$' TERM
# The EXIT trap covers the paths that leave through an early `exit` with a header still patched
# (a patch that would not compile, a build that failed). On those the status is already chosen,
# so the trap has to OVERRIDE it when the repair did not put the tree back - which is what makes
# REPAIR_RC readable at all from here. Calling `exit` inside an EXIT trap replaces the status and
# does not re-enter the trap.
on_exit() {
repair || true
if [ "$REPAIR_RC" -ne 0 ]; then
say "a repair did NOT put the tree back; exit 2 regardless of what this run was going to say."
say "THE BUILD DIRECTORY IS NOT TRUSTWORTHY: repair it before reading anything out of it."
exit 2
fi
}
trap 'on_exit' EXIT
# --- one control -----------------------------------------------------------------------------
# Sets the GLOBAL CONTROL_VERDICT to "tripped" / "did-not-trip" / "wrong-reason" /
# "could-not-run"; everything it has to say goes to stderr. The tree is repaired before it
# returns, whatever the answer.
#
# A GLOBAL AND NOT AN ECHO, AND IT IS CALLED PLAINLY AND NOT IN `$(...)` (review F-M2 and F-M3,
# two separate defects that the same change closes). The earlier form was
# `verdict=$(run_control ...)`, and a command substitution is a SUBSHELL:
#
# * F-M2 - anything the function or anything it called wrote to stdout became part of
# `$verdict`. The patcher's own success line did, so on a tree where a control really tripped
# the verdict was a TWO-LINE string, the `case` fell through to `*)`, and the run scored a
# control that had answered perfectly as "a control did not answer". Exit 0 was unreachable.
# The patcher now writes to stderr as well (belt and braces), but the verdict no longer
# travels through a stream that anything else can write to, which is the actual fix.
# * F-M3 - PATCHED_HEADER was assigned INSIDE that subshell. Bash resets a script's traps in a
# command substitution, so the subshell had no traps to fire, and its assignments never
# reached the parent, so the parent's EXIT/INT/TERM traps ran with PATCHED_HEADER empty and
# repair() returned 0 immediately. A Ctrl-C during a rebuild left the patched header in the
# tree - the exact thing the header's own paragraph promises cannot happen, and the p3a-g7 m3
# lesson it cites. Called plainly, the state is the parent's and the traps repair.
CONTROL_VERDICT=""
run_control() {
local header=$1 field=$2 test=$3 owner=$4
local tag matched
tag=$(basename "$header" .h)-$field
CONTROL_VERDICT=could-not-run
# 0. the control has to have something to break.
if [ ! -f "$header" ]; then
say "$header does not exist on this tree."
say "It is P4a package $owner's file (BRIEF-P4A.md C.5/C.7): the client-side emitter that"
say "carries the ${field} copy. Until it lands there is no field copy to drop, so this control"
say "cannot run and MUST NOT report a pass. Re-run on a tree that carries that package."
CONTROL_VERDICT=could-not-run
return
fi
if ! grep -qE "\.${field}[[:space:]]*=" "$header"; then
say "$header exists but assigns no .${field}."
say "On the P4a CONTRACT tree that is the expected answer: the contract commit creates all five"
say "emit headers with STUB emitters (contract-v1 D1) that return 0 payload bytes and copy"
say "nothing, and package $owner fills the body in. Later in the phase it means something else"
say "and worse - either the conversion moved out of this header, or it does not copy ${field}"
say "at all, and the second one would mean G6 is already broken in exactly the way this control"
say "is supposed to create. Neither is something this script may report as a pass; look at the"
say "header."
CONTROL_VERDICT=could-not-run
return
fi
# 1. the suite has to exist, and be green, BEFORE the break. A missing test is NOT a pass:
# without this the script would patch, watch ctest match nothing, read that as "the test
# failed" and report the control as tripped.
matched=$(ctest --test-dir "$BUILD_DIR" -N -R "$test" 2>/dev/null | grep -cE '^ *Test *#[0-9]+:')
if [ "${matched:-0}" -eq 0 ]; then
say "no test matches $test in $BUILD_DIR."
say "The suite is registered by the P4a contract commit; until it carries the emission cases"
say "this control has nothing to trip and cannot report a pass."
CONTROL_VERDICT=could-not-run
return
fi
say "[$tag] $matched matching test(s) before the patch"
say "[$tag] building $BUILD_DIR as it is"
if ! cmake --build "$BUILD_DIR" -j "$(nproc)" </dev/null > "$LOG_DIR/build-before-$tag.log" 2>&1; then
say "[$tag] the build is already broken before any patch - see $LOG_DIR/build-before-$tag.log"
tail -20 "$LOG_DIR/build-before-$tag.log" >&2
CONTROL_VERDICT=could-not-run
return
fi
if ! ctest --test-dir "$BUILD_DIR" -R "$test" --no-tests=error --output-on-failure </dev/null \
> "$LOG_DIR/ctest-before-$tag.log" 2>&1; then
say "[$tag] $test is already red before the patch - fix that first, the control proves nothing"
tail -30 "$LOG_DIR/ctest-before-$tag.log" >&2
CONTROL_VERDICT=could-not-run
return
fi
say "[$tag] $test is green before the patch"
# 2. stop copying the field.
PATCHED_BACKUP=$LOG_DIR/$(basename "$header").orig
PATCHED_TEST=$test
cp -f "$header" "$PATCHED_BACKUP" || { CONTROL_VERDICT=could-not-run; return; }
# Set BEFORE the patcher runs, not after: a python that died half-way through the write must
# still be repaired. The cost is one unnecessary rebuild in the case where the patcher matched
# nothing and the file is byte-identical (cp refreshes its mtime).
PATCHED_HEADER=$header
say "[$tag] dropping the ${field} copy from $header"
if ! python3 - "$header" "$field" <<'PY'
import re
import sys
path, field = sys.argv[1], sys.argv[2]
text = open(path, encoding='utf-8').read()
# `<lhs>.Layered = <expr>;` and the designated-initializer `.Layered = <expr>,`. The right-hand
# side is REPLACED rather than the line deleted, so the record still HAS the field and the break
# stays one the compiler cannot see.
pattern = re.compile(r'(\.' + re.escape(field) + r'\s*=\s*)([^;,\n]+)([;,])')
# `{}` and not `0`: borderColorForm is a SCOPED enum (enum class BorderColorForm : Uint8) and
# `= 0` does not convert, so the patched header would never compile and this control could
# never answer (review F-M1). `= {}` is valid for the scoped enum and for MGPSurface::Layered's
# Uint8 alike, in an assignment and in a designated initializer, so one string covers both.
patched, count = pattern.subn(r'\g<1>{} /* G7 NEGATIVE CONTROL: was \g<2> */\g<3>', text)
if count == 0:
sys.stderr.write('[p4a-g7] no assignment to .%s to patch - the header changed shape since this '
'control was written; update the control, do not delete it.\n' % field)
sys.exit(1)
open(path, 'w', encoding='utf-8', newline='\n').write(patched)
# STDERR, like every other diagnostic here: this used to be the one line in the file that
# went to stdout, and stdout was the channel the caller captured the verdict through.
sys.stderr.write('[p4a-g7] neutralised %d assignment(s) to .%s\n' % (count, field))
PY
then
say "[$tag] the patcher did not apply; the tree is repaired on the way out"
repair || true
CONTROL_VERDICT=could-not-run
return
fi
# 3. it must still COMPILE. A build break here would prove the static_asserts work, not that the
# suite still checks.
say "[$tag] rebuilding with the dropped field"
cmake --build "$BUILD_DIR" -j "$(nproc)" </dev/null > "$LOG_DIR/build-after-$tag.log" 2>&1
buildRc=$?
if child_was_signalled "$buildRc" || [ -n "$INTERRUPTED" ]; then
note_interrupt "exit $buildRc"
say "[$tag] INTERRUPTED during the rebuild ($INTERRUPTED). Repairing the tree and stopping:"
say "a run that carried on here would report a verdict about a control that never ran."
repair || true
CONTROL_VERDICT=could-not-run
return
fi
if [ "$buildRc" -ne 0 ]; then
say "[$tag] the patched header did not compile, so the control cannot tell 'the test failed'"
say "from 'nothing was built'. The break is supposed to be invisible to the compiler - if the"
say "field is read somewhere that needs its value, say so in the control rather than working"
say "around it."
grep -m10 -E 'error:' "$LOG_DIR/build-after-$tag.log" >&2
repair || true
CONTROL_VERDICT=could-not-run
return
fi
say "[$tag] the patched header still compiles, so the record still has the field and its size"
# 4. the suite must now be RED, and NAME the field. The verdict is only RECORDED here; nothing is
# reported and nothing returns until step 5 has put the tree back, because all three outcomes
# leave the same corrupted build directory behind.
say "[$tag] running $test against the dropped field"
ctest --test-dir "$BUILD_DIR" -R "$test" --no-tests=error --output-on-failure </dev/null \
> "$LOG_DIR/ctest-after-$tag.log" 2>&1
ctestRc=$?
if child_was_signalled "$ctestRc" || [ -n "$INTERRUPTED" ]; then
note_interrupt "exit $ctestRc"
say "[$tag] INTERRUPTED while running $test ($INTERRUPTED). A suite that was killed is not a"
say "suite that went red: repairing the tree and stopping rather than scoring the control."
repair || true
CONTROL_VERDICT=could-not-run
return
fi
if [ "$ctestRc" -eq 0 ]; then
CONTROL_VERDICT=did-not-trip
elif awk '/: Failure$/ || /: error:/ { block = 1 } block { print } /^[[:space:]]*$/ { block = 0 }' \
"$LOG_DIR/ctest-after-$tag.log" | grep -q "$field"; then
# A red is not yet a pass: a suite that had started failing for an unrelated reason satisfies
# the first half of the claim and none of the second. Matched against the FAILING ASSERTIONS'
# OWN BLOCKS rather than the whole ctest log (p3a-g7 m3): gtest prints a failure as
# `<file>:<line>: Failure` followed by the compared expressions and their values, terminated by
# a blank line, and the field name appears among those expressions - so the block, not the
# line, is the right unit and the whole file is the wrong one. A case NAMED after the field, a
# skip reason quoting it or a compiler note echoed into the log would all have made "tripped"
# mean "the string exists somewhere in the output".
CONTROL_VERDICT=tripped
else
CONTROL_VERDICT=wrong-reason
fi
# 5. put it back, and prove it went back. SHARED BY ALL THREE OUTCOMES, and that is the whole
# point of doing it before the verdict is reported.
if ! repair; then
say "[$tag] the control's own verdict was '$CONTROL_VERDICT', but the repair failed, so that"
say "verdict is not what this run reports: a build directory that could not be put back is"
say "'could not run'."
CONTROL_VERDICT=could-not-run
return
fi
case "$CONTROL_VERDICT" in
did-not-trip)
say "[$tag] NEGATIVE CONTROL DID NOT TRIP: $test was still green with ${field} no longer"
say "copied into the record. The emission comparison did not notice a field it claims to"
say "compare, so G6 is not checking what it claims to check."
say "The run's output is kept at $LOG_DIR/ctest-after-$tag.log"
;;
wrong-reason)
say "[$tag] INCONCLUSIVE: $test went red with ${field} dropped but never named it, so the red"
say "cannot be attributed to the dropped field. The suite failed for some other reason."
grep -m20 -E 'Failure|error|Expected|Actual' "$LOG_DIR/ctest-after-$tag.log" >&2
say "The run's output is kept at $LOG_DIR/ctest-after-$tag.log"
;;
tripped)
say "[$tag] negative control tripped, naming $field, and the tree is green again"
;;
esac
}
# --- both controls, then one verdict ----------------------------------------------------------
# Both are ALWAYS run, even when the first could not run: "FramebufferEmit has no emitter yet and
# SamplerEmit does" is a different tree from "neither does", and an engineer reading this output
# needs to know which. The exit code is the worst of the two, with 2 (could not run) outranking
# 1 (did not answer).
WORST=0
SUMMARY=""
# The rows are collected FIRST and the loop over them carries no redirection, so that
# run_control - which is called plainly now, in this shell, holding this shell's repair state -
# cannot have the remaining rows eaten out from under it by a child that reads stdin. (cmake and
# ctest are also given /dev/null explicitly below; this is the belt to that pair of braces.)
CONTROL_ROWS=()
while IFS= read -r mglRow; do
[ -n "$mglRow" ] && CONTROL_ROWS+=("$mglRow")
done <<EOF
$CONTROLS
EOF
for mglRow in "${CONTROL_ROWS[@]}"; do
IFS='@' read -r header field test owner <<< "$mglRow"
[ -n "$header" ] || continue
run_control "$header" "$field" "$test" "$owner"
verdict=$CONTROL_VERDICT
SUMMARY="$SUMMARY
$field ($header): $verdict"
case "$verdict" in
tripped) ;;
could-not-run) WORST=2 ;;
*) [ "$WORST" -eq 2 ] || WORST=1 ;;
esac
if [ -n "$INTERRUPTED" ]; then
SUMMARY="$SUMMARY
(INTERRUPTED - the remaining control(s) did not run)"
WORST=2
break
fi
done
# Every repair this run performed, read once. run_control already turns a failed repair of the
# control it is running into 'could-not-run', so this only ever fires for a repair that failed
# somewhere run_control does not report from - but the whole point of a sticky flag is that the
# verdict may not be better than the state of the build directory it was measured in.
if [ "$REPAIR_RC" -ne 0 ]; then
SUMMARY="$SUMMARY
(a repair did NOT put the tree back - the build directory is not trustworthy)"
WORST=2
fi
trap - EXIT
say "---- G7 (P4a descriptor emission) ----$SUMMARY"
case "$WORST" in
0) say "both controls tripped and named their field; exit 0" ;;
1) say "a control did not answer; exit 1" ;;
2) if [ -n "$INTERRUPTED" ]; then
say "the run was INTERRUPTED ($INTERRUPTED); exit 2. The tree was repaired before this line -"
say "\`git status\` is the check - and NOTHING here is a verdict about the controls."
else
say "a control could not be run; exit 2 (this is the expected answer on the P4a contract tree,"
say "where both emit headers are the contract's stubs and copy nothing)"
fi ;;
esac
# A run this shell interrupted still exits 2 rather than by signal, because the INT trap's
# re-raise only happens when bash actually reaches the trap. The summary above says which it was.
exit "$WORST"
+709
View File
@@ -0,0 +1,709 @@
#!/usr/bin/env bash
# G5's gate for P4a: the Espryt do-not-touch list is LITERAL - SEVENTEEN regions across THREE
# files stay byte-identical after P4a.
#
# WHAT G5 CLAIMS, and why a diff of the files cannot say it. ARCHITECTURE.md:318 is the Espryt
# do-not-touch list and :321 names the ONE item moved out of it (the sub-rect upload decision and
# the stride computation, which is P4a's D-D3/D-D6 subject). P3a made the buffer half of that list
# literal with a sha gate over eleven functions in Managers.cpp (scripts/p3a_untouched_regions.sh);
# P4a rewrites the rest of Managers.cpp, DirectGLES.cpp and Utils.cpp by design - the twins become
# handle-shaped, the descriptors replace the frontend reads - so the files' diffs are large and say
# nothing about whether the ring, the permutation, the depth/stencil sampling core or the format
# caveat moved. This gate extracts the seventeen BODIES and compares them on their own.
#
# THE SEVENTEEN. P3a's eleven are carried forward unchanged (BRIEF-P4A.md D-N: "P4a must not touch
# any of the eleven"), so this script is a superset of its parent and the two run side by side -
# the parent keeps answering P3a's question against P3a's base ref, this one answers P4a's.
#
# -- P3a's eleven, in Managers.cpp, in the parent's fixed order --
# IsPoolable takes the server-side resource, never the frontend object
# EnrollIntoPool the retireSerial = CurrentFrameSerial() + 1 stamp is load-bearing
# AcquireFromPool hands back only entries whose GPU work is complete
# TrimBufferPool called once per frame from Present
# ClearBufferPool context loss
# ProcessDeferredBufferReleases drained per draw, fast-outs on an atomic flag
# CreateRingStorage glBufferStorageEXT + persistent|coherent, retires at serial + 1
# RingAvailable self-heals a stale context generation
# RingAllocate the fast path on the hot upload route
# FlushPendingRangesNow the three-tier drain, pull arm
# FlushPendingRangesFrom the SAME three-tier drain, on the arm that ships (ID-15). Born
# in P3a, so its baseline is PINNED, not read from <ref-a>
#
# -- P4a's six (BRIEF-P4A.md D-N), all of which predate the phase --
# StageBlocksIntoUnpackRing Managers.cpp: the unpack-PBO staging repack. It is what makes the
# ring path issue NO glPixelStorei, and the +6 ms/frame Mali cliff
# lives on the other side of it
# UnpackRingAvailable Managers.cpp: honours Features.EsprytDisableUnpackRing and
# self-heals a stale context generation
# UnpackRingAllocate Managers.cpp: on the hot upload route
# RecomputeBackendColorSlots Managers.cpp: the attachment permutation - three passes plus the
# forced ~0 version memo on every moved attachment; removing the
# empty-point detach breaks the invariant (Managers.cpp:7651-7658)
# DepthStencilSamplingReadImpl DirectGLES.cpp: the D24S8 sampling-emulation core (memory
# better-clouds-fullmode), entered from :8617 and :8721
# ShouldUseCaveatTextureFormat Utils.cpp: the format handler P4a's InternalFormat descriptors
# feed; a change here silently changes what every texture is
# allocated as
#
# TWO THINGS THIS SCRIPT ADDS TO ITS PARENT, AND NOTHING ELSE (BRIEF-P4A.md D-N: "keeps every other
# property of its parent verbatim"):
#
# 1. A PER-REGION SOURCE PATH. The parent's SOURCE_PATH is a single file (:85); the seventeen live
# in three. Each row below carries its own path and the extractor reads each file once.
# 2. A REGION KIND. [declared deviation, see DEVIATIONS below] Sixteen of the seventeen are
# FUNCTIONS. `DepthStencilSamplingReadImpl` is a NAMESPACE (DirectGLES.cpp:8117-8578) - the
# brief's D-N table names it as though it were a function, and the parent's extractor, which
# finds a definition as the one `<name> (` whose closing paren is followed by `{`, finds ZERO
# definitions of it and exits 2 forever. Hashing the whole namespace block is also the
# stronger reading of "the D24S8 sampling-emulation CORE": the core is the ~460-line block of
# staging, conversion and readback, not any one function inside it.
#
# Everything else is the parent verbatim, and deliberately so:
#
# HOW A BODY IS EXTRACTED. The file is masked first - comments, string, char and raw-string
# literals are replaced by spaces of the same length, so a brace or a parenthesis inside one can
# never be counted - and the DEFINITION is then found as the one occurrence of `<name> (` whose
# closing parenthesis is followed (past qualifiers like const/noexcept) by `{`. That is what tells
# a definition from a forward declaration and from a call site: a call's `)` is followed by `)`,
# `;` or `,`, never by `{`. A NAMESPACE row is found as the one occurrence of `namespace <name>`
# followed by `{`. The body is then brace matched in the masked text and hashed from the ORIGINAL
# text, so a comment change inside one of these is a difference too - which is deliberate: the
# claim is "byte-identical", and a comment that stopped describing what the code does is exactly
# the kind of drift a "verbatim move" is supposed to be checked for.
#
# THE HASH'S EXACT EXTENT, so that the claim is not read wider than it is (review F-m3, inherited
# from the parent verbatim): it starts at the beginning of the LINE THAT CARRIES THE NAME and ends
# at the region's closing brace. A return type, an attribute or a template header sitting on an
# EARLIER line of a multi-line signature is therefore OUTSIDE the hash, and changing one of those
# alone does not move this gate. The end IS covered - the self-test's tail controls below prove
# the extent reaches the closing brace - and G1's symbol report is what catches a signature that
# changed shape.
#
# Exactly one definition must be found per name. Zero or two is exit 2 (could not run), never a
# silent pass: a rename this gate could not follow must not read as "nothing moved". Two is the
# expected shape of a #if/#else pair that re-spells one of these bodies beside an untouched copy -
# a finding, not a limitation, because two ladders drift (ID-11, ID-15).
#
# A SHARED TEMPLATE OVER AN ACCESSOR INTERFACE IS REJECTED (ID-13, MEASUREMENTS.md:521): it resizes
# pull symbols and breaks G1. If a P4a arm needs a variant of one of the seventeen it gets its own
# `#if MOBILEGL_PIPE_PUSH` function with its own name and its own pinned sha - the
# FlushPendingRangesFrom shape.
#
# Usage:
# scripts/p4a_untouched_regions.sh <ref-a> <ref-b> compare the seventeen at two git refs
# scripts/p4a_untouched_regions.sh <ref> print the seventeen shas at one ref (a
# baseline capture: ... > p4a-before.sha)
# scripts/p4a_untouched_regions.sh --self-test prove the comparison can go red
#
# stdout is always the sha list - `<sha256> <region>`, one per line, in the fixed order above - so
# a baseline capture is a plain redirect. Everything else goes to stderr.
#
# Both arguments are GIT REFS: the gate is about what landed, so an uncommitted edit is invisible
# by design. Use HEAD after committing, which is what D.1 and the CI row do.
#
# Exit codes: 0 the seventeen are identical at both refs (or a single ref was listed);
# 1 at least one moved - the first one in the fixed order is named on stderr;
# 2 the gate could not run: a bad ref, a missing file, a name that is not defined
# exactly once, or a self-test whose control failed to trip.
#
# DEVIATIONS from BRIEF-P4A.md D-N, both declared here rather than in a commit message so that the
# next reader of this file meets them:
# D-N/1 the namespace region kind, above.
# D-N/2 the PINNED baseline is CONSULTED UNCONDITIONALLY for FlushPendingRangesFrom, where the
# parent consults it only when <ref-a> does not define the function. D-N says that row is
# compared "against its pinned 3e298c9a sha", and at P4a's base ref the function DOES
# exist - so the parent's fallback would silently never fire and the pin would stop being
# the baseline the brief names. Both readings agree on this tree (measured: the body at
# 37da3c3a hashes to the pinned value); where they would ever disagree, this script says
# so on stderr and keeps the PIN, because the pin is the reviewed text.
set -u -o pipefail
# One row per region: <name>@<kind>@<path>. The ORDER is the fixed order the sha list is printed
# in and the order `compare_lists` names the first mover from; P3a's eleven keep their parent's
# positions so a reader can diff the two scripts' outputs.
REGIONS="\
IsPoolable@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
EnrollIntoPool@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
AcquireFromPool@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
TrimBufferPool@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
ClearBufferPool@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
ProcessDeferredBufferReleases@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
CreateRingStorage@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
RingAvailable@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
RingAllocate@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
FlushPendingRangesNow@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
FlushPendingRangesFrom@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
StageBlocksIntoUnpackRing@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
UnpackRingAvailable@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
UnpackRingAllocate@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
RecomputeBackendColorSlots@function@MobileGL/MG_Backend/DirectGLES/Managers.cpp
DepthStencilSamplingReadImpl@namespace@MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp
ShouldUseCaveatTextureFormat@function@MobileGL/MG_Backend/DirectGLES/Utils.cpp"
EXPECTED_FUNCTION_COUNT=17
# The one region born in P3a, so there is no body at P4a's base ref that this phase reviewed: its
# baseline is the sha captured at 3e298c9a, the commit at which the two-arm shape was reviewed and
# accepted (ID-15). See DEVIATIONS D-N/2 for why it is consulted unconditionally.
PINNED_FUNCTIONS="FlushPendingRangesFrom"
PINNED_BASELINE_REF=3e298c9a
PINNED_SHA_FlushPendingRangesFrom=37fc94ffc5991923d222d585daa3af6511d2352d255623026ce35a3b6963c4a6
# The regions the self-test perturbs, one negative control each. FOUR, exactly as D-N requires, and
# each is a different shape so that a control which only ever perturbed the easy one cannot leave
# the others unproven:
# ClearBufferPool P3a's easy control - small, no forward declaration, no overload, so
# a failure there is about the COMPARISON rather than the extraction
# FlushPendingRangesNow P3a's hard control - the longest body in the set, three nested
# tiers, its own early returns, and one of a PAIR of identically
# shaped bodies in two preprocessor arms
# RecomputeBackendColorSlots P4a's method-shaped control: a member function spelled
# `BackendFramebufferObject::RecomputeBackendColorSlots(` with a
# multi-line signature and a call site of its own, so a naive
# extraction picks the wrong occurrence
# StageBlocksIntoUnpackRing P4a's static-in-an-anonymous-namespace control, on the hot upload
# route, and the one whose file position sits between two other
# protected bodies
SELF_TEST_FUNCTIONS="ClearBufferPool FlushPendingRangesNow RecomputeBackendColorSlots StageBlocksIntoUnpackRing"
say() { echo "[p4a-untouched] $*" >&2; }
REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) || exit 2
cd "$REPO_ROOT" || exit 2
WORK_DIR=$(mktemp -d) || exit 2
trap 'rm -rf "$WORK_DIR"' EXIT
# Every distinct source path the rows name, in first-appearance order.
region_paths() {
printf '%s\n' "$REGIONS" | awk -F@ '!seen[$3]++ { print $3 }'
}
# The extractor. Three modes, all over a SPEC FILE of `<name>\t<kind>\t<file>` rows so that the
# self-test can drive it without inventing a commit: `extract` prints one `<sha> <name>` line per
# row in spec order, `perturb` writes a copy of one file with one statement inserted at the top of
# one region's body, `count` is unused by the shell and kept out.
PY=$WORK_DIR/extract.py
cat > "$PY" <<'PYTHON'
import hashlib
import re
import sys
def mask(text):
"""Comments and literals replaced by spaces of the same length, offsets preserved."""
out = list(text)
i, n = 0, len(text)
while i < n:
c = text[i]
if c == '/' and i + 1 < n and text[i + 1] == '/':
while i < n and text[i] != '\n':
out[i] = ' '
i += 1
elif c == '/' and i + 1 < n and text[i + 1] == '*':
out[i] = out[i + 1] = ' '
i += 2
while i + 1 < n and not (text[i] == '*' and text[i + 1] == '/'):
if text[i] != '\n':
out[i] = ' '
i += 1
if i + 1 < n:
out[i] = out[i + 1] = ' '
i += 2
elif c == 'R' and i + 1 < n and text[i + 1] == '"':
# R"delim( ... )delim" - a shader source is one of these, and it is full of braces.
close = text.find('(', i + 2)
if close < 0:
i += 1
continue
delim = text[i + 2:close]
end = text.find(')' + delim + '"', close)
end = n if end < 0 else end + len(delim) + 2
for j in range(i, end):
if text[j] != '\n':
out[j] = ' '
i = end
elif c == "'" and i > 0 and (text[i - 1].isdigit() or
(text[i - 1] in 'abcdefABCDEF' and i > 1 and
text[i - 2] in "0123456789abcdefABCDEFxX")):
# A C++14 DIGIT SEPARATOR (16'777'216, 0xff'ff), not a char literal. Treating it as one
# would blank forward to the next apostrophe - which can be a whole function away, in a
# comment - and silently swallow a brace, shifting a body's extent with no diagnostic.
i += 1
elif c in '"\'':
quote = c
out[i] = ' '
i += 1
while i < n and text[i] != quote:
if text[i] == '\\' and i + 1 < n:
out[i] = ' '
i += 1
if text[i] != '\n':
out[i] = ' '
i += 1
if i < n:
out[i] = ' '
i += 1
else:
i += 1
return ''.join(out)
def match_forward(masked, start, opener, closer):
depth = 0
for i in range(start, len(masked)):
if masked[i] == opener:
depth += 1
elif masked[i] == closer:
depth -= 1
if depth == 0:
return i
return -1
def find_function(text, masked, name):
"""Every (begin, end) at which `name` is DEFINED as a function."""
hits = []
for m in re.finditer(r'\b' + re.escape(name) + r'\s*\(', masked):
open_paren = m.end() - 1
close_paren = match_forward(masked, open_paren, '(', ')')
if close_paren < 0:
continue
tail = masked[close_paren + 1:close_paren + 96]
# Past whatever qualifiers a definition may carry; anything else means this was a call
# or a declaration.
stripped = re.sub(r'^(\s|const\b|noexcept\b|override\b|final\b)*', '', tail)
if not stripped.startswith('{'):
continue
brace = masked.index('{', close_paren)
end = match_forward(masked, brace, '{', '}')
if end < 0:
continue
begin = text.rfind('\n', 0, m.start()) + 1
hits.append((begin, end + 1))
return hits
def find_namespace(text, masked, name):
"""Every (begin, end) at which `name` is DEFINED as a namespace.
P4a's DepthStencilSamplingReadImpl is a namespace, not a function (DirectGLES.cpp:8117-8578):
the D24S8 sampling-emulation CORE is the whole block, and a function-shaped search finds no
definition of it at all. The closing `} // namespace <name>` comment is masked away by the
time this runs, so the extent is decided by brace matching exactly as a function's is - and a
USE of the namespace (`DepthStencilSamplingReadImpl::Read(...)`) is not matched, because the
`namespace` keyword is not in front of it.
"""
hits = []
for m in re.finditer(r'\bnamespace\s+' + re.escape(name) + r'\b', masked):
rest = masked[m.end():m.end() + 96]
if not rest.lstrip().startswith('{'):
continue
brace = masked.index('{', m.end())
end = match_forward(masked, brace, '{', '}')
if end < 0:
continue
begin = text.rfind('\n', 0, m.start()) + 1
hits.append((begin, end + 1))
return hits
def find_definition(text, masked, name, kind):
if kind == 'namespace':
return find_namespace(text, masked, name)
return find_function(text, masked, name)
def read_spec(path):
rows = []
with open(path, encoding='utf-8') as spec:
for line in spec:
line = line.rstrip('\n')
if not line:
continue
name, kind, source = line.split('\t')
rows.append((name, kind, source))
return rows
class Sources(object):
"""Each file read and masked once, however many regions name it."""
def __init__(self):
self.cache = {}
def get(self, path):
if path not in self.cache:
text = open(path, encoding='utf-8', newline='').read()
self.cache[path] = (text, mask(text))
return self.cache[path]
def extract(rows):
sources = Sources()
out, problems = [], []
for name, kind, source in rows:
try:
text, masked = sources.get(source)
except OSError as err:
problems.append('%s: cannot read %s (%s)' % (name, source, err))
continue
hits = find_definition(text, masked, name, kind)
if len(hits) != 1:
problems.append('%s: expected exactly one %s definition in %s, found %d'
% (name, kind, source, len(hits)))
if len(hits) > 1:
# The expected shape of this failure, and it is a FINDING rather than a limitation:
# a `#if MOBILEGL_PIPE_PUSH` arm that re-spells one of these bodies beside an
# untouched `#else` copy satisfies G1 (the pull build's text did not move) and
# defeats G5 (the push build compiles a second copy that can drift). The gate
# cannot say which of the two is "the" body, and must not pick one.
for begin, end in hits:
problems.append(' ...definition at line %d, %d lines'
% (text[:begin].count('\n') + 1,
text[:end].count('\n') - text[:begin].count('\n') + 1))
problems.append(' the P4a arm must CALL the untouched %s, not carry a copy of it: '
'two ladders drift (BRIEF-P4A.md D-N, ID-11, ID-15)' % name)
continue
begin, end = hits[0]
body = text[begin:end]
out.append((hashlib.sha256(body.encode('utf-8')).hexdigest(), name))
return out, problems
def perturb(rows, target, src, dst, where='head'):
"""Insert one line into a region's body, at its HEAD or at its TAIL.
TWO POSITIONS, AND THE SECOND ONE IS REVIEW FINDING F-m2. Every control used to insert at the
very first byte after the opening brace, so all four of them would still have tripped if
find_definition() returned an extent that stopped short of the closing brace - nothing in the
self-test proved that a region reaches its own end, and an extractor that hashed all but the
last statement of every body would have passed the whole self-test while being blind to a
change in that statement. The `tail` position inserts immediately BEFORE the closing brace,
which is the byte such an extractor would have dropped.
"""
for name, kind, source in rows:
if name != target:
continue
text = open(src, encoding='utf-8', newline='').read()
masked = mask(text)
hits = find_definition(text, masked, name, kind)
if len(hits) != 1:
sys.stderr.write('[p4a-untouched] cannot perturb %s: %d definitions\n'
% (target, len(hits)))
return 2
begin, end = hits[0]
if where == 'tail':
# end is one PAST the closing brace (find_function / find_namespace both return
# `match_forward(...) + 1`), so end - 1 is the brace itself and this lands inside the
# body, one character before it ends.
note = '\n // p4a_untouched_regions.sh --self-test: a body whose TAIL moved.\n'
patched = text[:end - 1] + note + text[end - 1:]
else:
# The opening brace is located in the MASKED text and then used as an offset into the
# original: a brace inside a comment or a string on the signature line would otherwise
# send the perturbation somewhere that is not the body, and the control would be
# proving the wrong thing. Offsets are identical between the two by construction
# (mask() preserves length).
brace = masked.index('{', begin)
patched = (text[:brace + 1] +
'\n // p4a_untouched_regions.sh --self-test: a body that MOVED.\n' +
text[brace + 1:])
open(dst, 'w', encoding='utf-8', newline='').write(patched)
return 0
sys.stderr.write('[p4a-untouched] %s is not one of the regions\n' % target)
return 2
def main(argv):
# The sha list is parsed by awk, and on Windows (Git Bash, MSYS python) text-mode stdout
# translates '\n' into CRLF - after which `$2 == n` never matches and the gate exits 1 on an
# untouched tree. Linux CI never saw it; a developer running the gate locally always did.
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(newline='\n')
mode = argv[1]
rows = read_spec(argv[2])
if mode == 'extract':
found, problems = extract(rows)
for problem in problems:
sys.stderr.write('[p4a-untouched] %s\n' % problem)
for sha, name in found:
sys.stdout.write('%s %s\n' % (sha, name))
return 2 if problems else 0
if mode == 'perturb':
return perturb(rows, argv[3], argv[4], argv[5], argv[6] if len(argv) > 6 else 'head')
sys.stderr.write('[p4a-untouched] unknown mode %r\n' % mode)
return 2
sys.exit(main(sys.argv))
PYTHON
# Write a spec file whose rows point at the blobs in "$2" (a directory holding one file per
# region path, named by a sanitised path so two files of the same basename could never collide).
blob_name() { printf '%s' "$1" | tr '/' '_'; }
write_spec() {
local dir=$1 spec=$2 row name kind source
: > "$spec"
printf '%s\n' "$REGIONS" | while IFS='@' read -r name kind source; do
[ -n "$name" ] || continue
printf '%s\t%s\t%s/%s\n' "$name" "$kind" "$dir" "$(blob_name "$source")" >> "$spec"
done
}
# Materialise every region source at a git ref into "$2".
checkout_ref() {
local ref=$1 dir=$2 source
mkdir -p "$dir" || return 2
for source in $(region_paths); do
if ! git show "$ref:$source" > "$dir/$(blob_name "$source")" 2>"$WORK_DIR/show.err"; then
say "cannot read $source at '$ref':"
sed 's/^/[p4a-untouched] /' "$WORK_DIR/show.err" >&2
return 2
fi
done
return 0
}
# Extract at a git ref into "$WORK_DIR/$2.sha". Every region must be defined exactly once there;
# this is the side the gate is ABOUT (<ref-b>, and the single-ref listing's ref).
extract_ref() {
local ref=$1 out=$2
checkout_ref "$ref" "$WORK_DIR/$out" || return 2
write_spec "$WORK_DIR/$out" "$WORK_DIR/$out.spec"
python3 "$PY" extract "$WORK_DIR/$out.spec" > "$WORK_DIR/$out.sha"
return $?
}
# The BASELINE side (<ref-a>). The sixteen ordinary regions are extracted strictly, so a rename of
# one of THOSE is exit 2 rather than a silently short list. FlushPendingRangesFrom then takes the
# PINNED sha (DEVIATIONS D-N/2) whether or not <ref-a> defines it, and a <ref-a> that defines it
# DIFFERENTLY is reported - loudly - because the two answers disagreeing is itself a finding.
extract_baseline() {
local ref=$1 out=$2 name pinned atRef
checkout_ref "$ref" "$WORK_DIR/$out" || return 2
write_spec "$WORK_DIR/$out" "$WORK_DIR/$out.spec.all"
# The strict pass, minus the pinned rows.
cp -f "$WORK_DIR/$out.spec.all" "$WORK_DIR/$out.spec" || return 2
for name in $PINNED_FUNCTIONS; do
grep -v "^$name$(printf '\t')" "$WORK_DIR/$out.spec" > "$WORK_DIR/$out.spec.tmp" || true
mv -f "$WORK_DIR/$out.spec.tmp" "$WORK_DIR/$out.spec" || return 2
done
if ! python3 "$PY" extract "$WORK_DIR/$out.spec" > "$WORK_DIR/$out.sha" 2>"$WORK_DIR/$out.err"; then
say "the baseline ref '$ref' does not define the sixteen unpinned regions exactly once each:"
sed 's/^/[p4a-untouched] /' "$WORK_DIR/$out.err" >&2
return 2
fi
for name in $PINNED_FUNCTIONS; do
eval "pinned=\$PINNED_SHA_$name"
if [ -z "$pinned" ] || [ "$pinned" = "PLACEHOLDER_SHA" ]; then
say "$name has no pinned baseline sha; that row cannot be compared"
return 2
fi
grep "^$name$(printf '\t')" "$WORK_DIR/$out.spec.all" > "$WORK_DIR/$out.spec.pinned" || true
atRef=$(python3 "$PY" extract "$WORK_DIR/$out.spec.pinned" 2>/dev/null | awk '{ print $1 }')
if [ -n "$atRef" ] && [ "$atRef" != "$pinned" ]; then
say "NOTE: $name IS defined at '$ref' and hashes $atRef, which is NOT the sha pinned in this"
say " script ($pinned, captured at $PINNED_BASELINE_REF). The PIN is what is compared - it is"
say " the reviewed text (ID-15) - but the two disagreeing means the push ladder moved between"
say " $PINNED_BASELINE_REF and '$ref' without this gate being re-pinned. Re-pin deliberately or"
say " revert; do not leave them disagreeing."
fi
printf '%s %s\n' "$pinned" "$name" >> "$WORK_DIR/$out.sha"
done
# ...and put the list back into the FIXED ORDER (review F-m1). The pinned rows were stripped out
# of the spec above and appended here, so without this the baseline side emits them LAST while
# extract_ref emits everything in REGIONS order. The gate itself never noticed - compare_lists
# looks rows up by name - but the documented capture workflow did: the header promises "stdout is
# always the sha list ... in the fixed order above - so a baseline capture is a plain redirect",
# and a baseline captured that way then diffed against a two-ref stdout showed seven spurious
# differences purely from row order.
reorder_sha_list "$WORK_DIR/$out.sha" || return 2
return 0
}
# Rewrite a `<sha> <region>` list in REGIONS order, in place. Rows whose name is not in REGIONS
# would be a bug in the caller rather than a difference, so they are kept at the end where they are
# visible instead of being dropped.
reorder_sha_list() {
local file=$1 name
: > "$file.ordered" || return 2
printf '%s\n' "$REGIONS" | awk -F@ '{ print $1 }' > "$WORK_DIR/reorder.names" || return 2
while read -r name; do
[ -n "$name" ] || continue
awk -v n="$name" '$2 == n { print }' "$file" >> "$file.ordered" || return 2
done < "$WORK_DIR/reorder.names"
awk 'NR == FNR { known[$0] = 1; next } !($2 in known) { print }' \
"$WORK_DIR/reorder.names" "$file" >> "$file.ordered" || return 2
mv -f "$file.ordered" "$file" || return 2
return 0
}
# Compare two sha lists. Prints the first region that moved, in REGIONS order.
compare_lists() {
local a=$1 b=$2 labelA=$3 labelB=$4 moved=0 name shaA shaB
printf '%s\n' "$REGIONS" | awk -F@ '{ print $1 }' > "$WORK_DIR/order"
while read -r name; do
[ -n "$name" ] || continue
shaA=$(awk -v n="$name" '$2 == n { print $1 }' "$a")
shaB=$(awk -v n="$name" '$2 == n { print $1 }' "$b")
if [ "$shaA" != "$shaB" ]; then
if [ "$moved" -eq 0 ]; then
say "FIRST REGION THAT MOVED: $name"
say " $labelA ${shaA:-<not found>}"
say " $labelB ${shaB:-<not found>}"
say " G5 (ARCHITECTURE.md:318, :321, :515) says the Espryt do-not-touch list is literal:"
say " P3a's buffer pool, deferred-release drain, three rings and BOTH arms of the three-tier"
say " flush drain, plus P4a's unpack-PBO staging repack and its two ring helpers, the"
say " attachment permutation, the D24S8 sampling-emulation core and the format-caveat"
say " handler, all move VERBATIM. If this change is intended it is not a P4a change and it"
say " needs its own commit and its own reason; if it is not, revert the body. A P4a arm that"
say " carries its own COPY of one of these beside an untouched one is the same finding: the"
say " new arm must CALL the untouched region, not re-spell it."
else
say "also moved: $name"
fi
moved=$((moved + 1))
fi
done < "$WORK_DIR/order"
return $((moved > 0 ? 1 : 0))
}
# --- self-test ------------------------------------------------------------------------------
# A gate that always says "identical" and a gate that is working produce the same green, so the
# comparison has to be shown failing. Both controls run: the POSITIVE ones (an untouched copy
# compares equal; an edit OUTSIDE the regions is invisible) rule out a comparison that reports
# every region as moved, and the eight NEGATIVE ones - D-N's four regions, each perturbed at the
# HEAD of its body and again at its TAIL - rule out both the comparison that never reports any and
# the extraction whose extent stops before the closing brace (F-m2).
if [ "${1:-}" = "--self-test" ]; then
[ $# -eq 1 ] || { say "--self-test takes no other arguments"; exit 2; }
mkdir -p "$WORK_DIR/pristine" || exit 2
for source in $(region_paths); do
[ -f "$source" ] || { say "$source is not in this tree"; exit 2; }
cp -f "$source" "$WORK_DIR/pristine/$(blob_name "$source")" || exit 2
done
write_spec "$WORK_DIR/pristine" "$WORK_DIR/pristine.spec"
if ! python3 "$PY" extract "$WORK_DIR/pristine.spec" > "$WORK_DIR/pristine.sha"; then
say "the extractor could not read the $EXPECTED_FUNCTION_COUNT regions out of the working tree"
exit 2
fi
found=$(wc -l < "$WORK_DIR/pristine.sha")
if [ "$found" -ne "$EXPECTED_FUNCTION_COUNT" ]; then
say "extracted $found regions, expected $EXPECTED_FUNCTION_COUNT"
exit 2
fi
say "positive control: $EXPECTED_FUNCTION_COUNT regions extracted from the working tree"
cp -r "$WORK_DIR/pristine" "$WORK_DIR/copy" || exit 2
write_spec "$WORK_DIR/copy" "$WORK_DIR/copy.spec"
python3 "$PY" extract "$WORK_DIR/copy.spec" > "$WORK_DIR/copy.sha" || exit 2
if ! compare_lists "$WORK_DIR/pristine.sha" "$WORK_DIR/copy.sha" "pristine" "copy" 2>/dev/null; then
say "POSITIVE CONTROL FAILED: an untouched copy compared as MOVED. The comparison is reporting"
say "differences that are not there, so its verdict means nothing in either direction."
exit 2
fi
say "positive control: an untouched copy compares equal"
# The second positive control, and it is the one that matters for P4a: the rest of these three
# files IS going to be rewritten (the twins become handle-shaped, the descriptors replace the
# frontend reads), so a gate that fired on any edit to them would have to be switched off in the
# same week it landed. An edit outside the seventeen must be invisible here - in EVERY file, so
# that a per-file extraction bug cannot hide behind the one file that was probed.
cp -r "$WORK_DIR/pristine" "$WORK_DIR/outside" || exit 2
for source in $(region_paths); do
{ echo "// p4a_untouched_regions.sh --self-test: an edit OUTSIDE the seventeen regions."; \
cat "$WORK_DIR/pristine/$(blob_name "$source")"; } > "$WORK_DIR/outside/$(blob_name "$source")"
done
write_spec "$WORK_DIR/outside" "$WORK_DIR/outside.spec"
python3 "$PY" extract "$WORK_DIR/outside.spec" > "$WORK_DIR/outside.sha" || exit 2
if ! compare_lists "$WORK_DIR/pristine.sha" "$WORK_DIR/outside.sha" "pristine" "outside" \
2>/dev/null; then
say "POSITIVE CONTROL FAILED: an edit OUTSIDE the seventeen regions was reported as one of them"
say "moving. This gate would fire on every P4a commit to these three files and would have to be"
say "silenced, which is the same as not having it."
exit 2
fi
say "positive control: an edit outside the seventeen regions is invisible, in all three files"
# TWO negative controls per SELF_TEST_FUNCTIONS entry - one at the HEAD of the body and one at
# its TAIL. Each is run on its own, from the pristine copy, so the message it produces has to
# NAME that region: a control that only proved "some region moved" would not distinguish "this
# row is compared" from "this row is extracted as an empty range and every comparison of it is
# vacuous".
#
# THE TAIL HALF IS REVIEW FINDING F-m2. With head-only controls, an extraction that returned an
# extent stopping short of the closing brace would still have tripped all four - the inserted
# line is at the very first byte of the body - so nothing here proved that a region reaches its
# own end, and a change to the LAST statement of a protected body would have been invisible to a
# gate whose self-test was fully green. The tail control inserts immediately before the closing
# brace, which is exactly the byte such an extractor would have dropped.
controls=0
for target in $SELF_TEST_FUNCTIONS; do
targetSource=$(printf '%s\n' "$REGIONS" | awk -F@ -v n="$target" '$1 == n { print $3 }')
[ -n "$targetSource" ] || { say "$target is not one of the regions"; exit 2; }
for position in head tail; do
rm -rf "$WORK_DIR/perturbed"
cp -r "$WORK_DIR/pristine" "$WORK_DIR/perturbed" || exit 2
write_spec "$WORK_DIR/perturbed" "$WORK_DIR/perturbed.spec"
python3 "$PY" perturb "$WORK_DIR/perturbed.spec" "$target" \
"$WORK_DIR/pristine/$(blob_name "$targetSource")" \
"$WORK_DIR/perturbed/$(blob_name "$targetSource")" "$position" || exit 2
python3 "$PY" extract "$WORK_DIR/perturbed.spec" > "$WORK_DIR/perturbed.sha" || exit 2
if compare_lists "$WORK_DIR/pristine.sha" "$WORK_DIR/perturbed.sha" "pristine" "perturbed" \
2> "$WORK_DIR/perturbed.err"; then
say "NEGATIVE CONTROL DID NOT TRIP: $target's body was changed at its $position and the"
say "comparison still reported every region as identical. This gate cannot go red for the"
say "reason it exists, so every green it has ever printed means nothing."
if [ "$position" = tail ]; then
say " A TAIL control that does not trip while the head one does means the extracted"
say " extent stops before the closing brace: the last statement of every protected body"
say " is outside the hash and can be rewritten silently."
fi
exit 2
fi
if ! grep -q "FIRST REGION THAT MOVED: $target" "$WORK_DIR/perturbed.err"; then
say "NEGATIVE CONTROL TRIPPED FOR THE WRONG REASON: the comparison went red but did not name"
say "$target as the first region that moved ($position control). It said:"
sed 's/^/[p4a-untouched] /' "$WORK_DIR/perturbed.err" >&2
exit 2
fi
controls=$((controls + 1))
say "negative control $controls: a perturbed $target body ($position) is reported, and named"
done
done
if [ "$controls" -ne 8 ]; then
say "expected EIGHT negative controls (BRIEF-P4A.md D-N's four regions, each at its head and at"
say "its tail), ran $controls"
exit 2
fi
say "self-test passed: $controls negative controls, all tripped and all named"
exit 0
fi
# --- the gate -------------------------------------------------------------------------------
case $# in
1)
extract_baseline "$1" one || exit 2
cat "$WORK_DIR/one.sha"
say "listed the $EXPECTED_FUNCTION_COUNT regions at $1"
exit 0
;;
2) ;;
*)
say "usage: $0 <ref-a> <ref-b> | $0 <ref> | $0 --self-test"
exit 2
;;
esac
extract_baseline "$1" a || exit 2
extract_ref "$2" b || exit 2
cat "$WORK_DIR/b.sha"
if compare_lists "$WORK_DIR/a.sha" "$WORK_DIR/b.sha" "$1" "$2"; then
say "the $EXPECTED_FUNCTION_COUNT pool / ring / unpack-staging / attachment-permutation /"
say "depth-stencil-sampling / format-caveat regions are byte-identical between $1 and $2"
exit 0
fi
exit 1