Compare commits

..
77 Commits
Author SHA1 Message Date
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
57 changed files with 19194 additions and 330 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.
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
+584 -14
View File
@@ -26,36 +26,606 @@
// 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. ----
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() {
+116 -4
View File
@@ -33,23 +33,135 @@
// 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_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;
// 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() {
+236 -11
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());
}
@@ -2171,7 +2365,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 +2375,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 +2461,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 +2544,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.
//
+412 -8
View File
@@ -36,38 +36,442 @@
// 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.
//
// NO PRODUCTION CALLER TODAY, stated rather than implied: since c0b the death path
// reads the contract's latch and never asks an emitter. It is kept because the memo
// above needs a way to be told, and because everything it clears SELF-HEALS if it is
// not called - the slot's Gen moves on reuse, so `RecordGen == handle.Gen` refuses a
// stale record latch, and the three bound mirrors below hold a handle whose generation
// can never be handed out again, so the next EmitShaderState compares against a
// different handle and re-binds. Clearing them here is the cheaper answer, not the
// load-bearing one.
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)] =
+367 -29
View File
@@ -53,6 +53,8 @@ add_executable(MobileGLIntegrationTest
Harness/HeadlessGL.cpp
Harness/BackendCapsPeek.cpp
Harness/PipeSlotPeek.cpp
Harness/PipeApplyPeek.cpp
Harness/P4aSeamPeek.cpp
Scenarios/OrientationScenario.cpp
Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.cpp
@@ -133,6 +135,10 @@ add_executable(MobileGLIntegrationTest
Scenarios/HandleRecycleScenario.cpp
Scenarios/CsoContentAddressingScenario.cpp
Scenarios/ResourceSubsystemControlScenario.cpp
Scenarios/TextureParamsWithoutASamplerViewScenario.cpp
Scenarios/TextureUploadShapeScenario.cpp
Scenarios/ObjectSubsystemControlScenario.cpp
Scenarios/P4aSeamAuditScenario.cpp
)
target_include_directories(MobileGLIntegrationTest PRIVATE
@@ -405,6 +411,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 +550,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 +647,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
@@ -939,14 +1085,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 +1198,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 +1270,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 +1299,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 +1333,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 +1369,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 +1394,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,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,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
+93
View File
@@ -1094,6 +1094,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 +1149,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 +1580,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,6 +1619,12 @@ 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");
@@ -1711,6 +1753,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 +2246,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 +2365,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 +2424,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
@@ -2411,6 +2471,11 @@ namespace MobileGL::MG_Pipe {
}
void 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;
// 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
@@ -2458,7 +2523,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 +2542,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 +2557,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 +2628,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 +2663,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 +2724,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 +2739,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 +2755,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;
+29
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).
+5 -1
View File
@@ -56,7 +56,11 @@ namespace MobileGL::MG_Pipe {
enum class MGPipeAggregate : Uint32 {
// VertexArrayState: any VAO attribute format / buffer / enable moved.
VaoAttribute = 0,
// FramebufferState: any FBO attachment or default-geometry write, or a bind.
// FramebufferState: any FBO attachment or default-geometry write, or a bind - and, since
// P4a (fable seam F-3), any STORAGE DEFINITION of a texture or a renderbuffer, because
// set_framebuffer_state inlines an attachment's format, extent and samples and those
// setters are the only writers of what it inlines (TextureObject.cpp /
// RenderbufferObject.cpp, PipePublishDescriptor).
FramebufferAttachment,
// TextureState: any texture object CONTENT moved (an upload, a dirty region).
TextureContent,
@@ -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,60 @@ 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);
// 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::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 +121,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 +196,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 +230,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 +248,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 +266,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 +319,9 @@ namespace MobileGL {
}
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
void TextureObjectBase::SetSwizzleParamRGBA(const Vec4<TextureSwizzleParam>& values) {
@@ -230,6 +330,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 +352,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 +370,9 @@ namespace MobileGL {
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
BumpShapeVersion();
#if MOBILEGL_PIPE_PUSH
PipePublishParams();
#endif
}
Bool TextureObjectBase::IsImmutable() const {
@@ -281,6 +393,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 +439,9 @@ namespace MobileGL {
m_samples = samples;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
Bool TextureObjectBase::HasFixedSampleLocations() const {
@@ -329,6 +452,9 @@ namespace MobileGL {
m_fixedSampleLocations = fixedSampleLocations;
++m_textureParamsVersion;
MGP_NOTE_AGGREGATE(TextureParams);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
Uint64 TextureObjectBase::GetLifetimeId() const {
@@ -360,11 +486,23 @@ 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.
PipePublishDescriptor();
#endif
}
void TextureObjectWithOneMipmap::TruncateMipmapLevels(TextureUploadTarget uploadTarget, Uint levelCount) {
BumpShapeVersion();
m_textureStorage.TruncateToLevelCount(GetIndexOfTextureUploadTarget(uploadTarget), levelCount);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
void TextureObjectWithOneMipmap::UpdateMipmapSubData(TextureUploadTarget uploadTarget, Uint mipmapLevel,
@@ -383,6 +521,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 +538,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,34 @@ 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. Called from BumpShapeVersion and from the three parameter
// setters that move a DESCRIPTOR field without moving the shape (immutable levels,
// sample count, fixed sample locations). The emitter dedupes on the built descriptor,
// so an over-call costs one 88-byte compare and never an extra record.
void PipePublishDescriptor();
// 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,20 @@ 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.
PipePublishDescriptor();
#endif
}
void TextureObject2DCube::TruncateMipmapLevels(TextureUploadTarget uploadTarget, Uint levelCount) {
BumpShapeVersion();
m_textureStorage.TruncateToLevelCount(GetIndexOfTextureUploadTarget(uploadTarget), levelCount);
#if MOBILEGL_PIPE_PUSH
PipePublishDescriptor();
#endif
}
void TextureObject2DCube::UpdateMipmapSubData(TextureUploadTarget uploadTarget, Uint mipmapLevel,
@@ -53,6 +62,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 +79,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 {
+417 -4
View File
@@ -50,8 +50,14 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.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 +78,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 +121,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 +139,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 +370,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 +768,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();
@@ -58,7 +58,15 @@
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#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 +210,570 @@ 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)
#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;
}
#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 +1147,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();
+219 -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,14 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.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/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 +69,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 +112,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 +130,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 +176,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 +341,184 @@ 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)
#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);
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
@@ -334,6 +530,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();
+327 -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,17 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include "Init.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 +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
@@ -101,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;
@@ -113,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;
}
@@ -161,10 +186,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 +492,291 @@ 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)
#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);
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
@@ -482,6 +788,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();
+213
View File
@@ -156,6 +156,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;
@@ -1355,6 +1376,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 +1417,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.
+576 -4
View File
@@ -53,8 +53,16 @@
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
#if MOBILEGL_PIPE_PUSH
#include <cstring>
#include "Init.h"
#include <MG_Impl/GLImpl/Program/GL_Program.h>
#include <MG_Impl/GLImpl/Texture/GL_Texture.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 +80,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 +123,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 +141,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 +549,548 @@ 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)
#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);
}
} // namespace
#endif // MOBILEGL_PIPE_PUSH
int main(int argc, char** argv) {
namespace fs = std::filesystem;
const fs::path path =
@@ -535,6 +1102,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");
}
+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 -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