diff --git a/.github/workflows/apk.yml b/.github/workflows/apk.yml index a8a53269c..cd3c4fe49 100644 --- a/.github/workflows/apk.yml +++ b/.github/workflows/apk.yml @@ -6,6 +6,10 @@ on: - dev - Feat/Backend-Direct-GLES - Feat/Backend-Direct-Vulkan + # TEMPORARY, remove before merging the MGPipe work into dev: the disaggregation + # branch runs the full lane on every push so a phase's landing is not gated on + # someone remembering to dispatch the workflow by hand. + - feat/disaggregated workflow_dispatch: jobs: @@ -61,6 +65,8 @@ jobs: uses: android-actions/setup-android@v4 with: accept-android-sdk-licenses: false + packages: platform-tools + cmdline-tools-version: 13114758 - name: Accept Android SDK licenses run: yes | sdkmanager --licenses >/dev/null @@ -293,6 +299,8 @@ jobs: uses: android-actions/setup-android@v4 with: accept-android-sdk-licenses: false + packages: platform-tools + cmdline-tools-version: 13114758 - name: Accept Android SDK licenses run: yes | sdkmanager --licenses >/dev/null @@ -368,6 +376,8 @@ jobs: uses: android-actions/setup-android@v4 with: accept-android-sdk-licenses: false + packages: platform-tools + cmdline-tools-version: 13114758 - name: Accept Android SDK licenses run: yes | sdkmanager --licenses >/dev/null diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c343614da..3727fffa7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test +name: Test on: push: @@ -6,7 +6,19 @@ on: - dev - Feat/Backend-Direct-GLES - Feat/Backend-Direct-Vulkan + # TEMPORARY, remove before merging the MGPipe work into dev: the disaggregation + # branch runs the full lane on every push so a phase's landing is not gated on + # someone remembering to dispatch the workflow by hand. + - feat/disaggregated workflow_dispatch: + inputs: + baseline_sha: + description: >- + The commit monolith-symbol-report compares this tree against. P1's G1 says the pull + build is byte-identical to feat/disaggregated@087685d1, and that is what the default + names. The trigger set is unchanged: this job runs on workflow_dispatch only. + required: false + default: "087685d1" jobs: build-linux: @@ -295,6 +307,1214 @@ jobs: path: /tmp/core.* if-no-files-found: ignore + # THE THIRD CI MODE (ARCHITECTURE.md 13.2-(2)): the same library, built with the PipeInputs + # comparator compiled in, running the integration suite and a trace subset with two state models + # in one address space. It is a second build rather than a flag on the first because + # MOBILEGL_PIPE_VERIFY is a compile-time option - the snapshot, the entry compare and the + # compare-at-read hook do not exist in the shipped library, and are never meant to. + build-linux-verify: + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + actions: write + contents: read + env: + BUILD_DIR: build-verify + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPRESS: "true" + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 4G + CCACHE_NOHASHDIR: "true" + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 32 + + - name: Checkout repo + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Restore ccache + uses: actions/cache/restore@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + restore-keys: | + ${{ runner.os }}-test-${{ github.job }}-ccache- + + - name: Prepare Vulkan SDK + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Update glslang external sources + working-directory: 3rdparty/glslang + run: python update_glslang_sources.py + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + + - name: Show installed toolchain + run: | + ccache --version + clang-20 --version + clang++-20 --version + ld.lld-20 --version || ld.lld --version || true + dpkg -l 'libc++*' 'libegl*' 'libgles*' 'mesa*' 'vulkan*' || true + + - name: Configure CMake + # Release/INFO like the shipped build on purpose. The poison arms in this configuration + # through MOBILEGL_PIPE_VERIFY (PipeInputs.h derives MOBILEGL_PIPE_POISON from it), so this + # job needs neither a Debug log level nor MOBILEGL_BUILD_DISAGGREGATED - and a Debug build + # would compare a different library from the one the other lanes measure. (build-linux + # switches to Debug under ACTIONS_STEP_DEBUG; this job deliberately does not - a Debug + # build flips CXX_VISIBILITY_PRESET and arms MOBILEGL_PIPE_POISON through a second, unrelated + # arm of its #if, so the debug switch would change what the lane is measuring.) + run: | + cmake -S . -B "${BUILD_DIR}" -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 \ + -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=ON \ + -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=ON \ + -DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json \ + -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_PIPE_VERIFY=ON \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + + - name: Build + run: cmake --build "${BUILD_DIR}" --parallel "$(nproc)" + + # The lane is worthless if the option silently did not take, and that is a one-character + # mistake away at all times (a typo'd -D is not an error in CMake). Two checks, both cheap: + # the comparator's entry point must be in the library, and the fill entry point with it. + # + # `nm` and NOT `nm -D`. The library is built CXX_VISIBILITY_PRESET hidden in every non-Debug + # configuration (CMakeLists.txt:600-604) and the MGPipe entry points are plain namespace + # functions with no export attribute, so not one of them appears in the DYNAMIC table: on a + # perfectly healthy verify build `nm -D --defined-only ... | grep -c MGPipe` answers 0 out of + # ~11900 exported symbols, and a gate spelled that way is red forever for a reason that has + # nothing to do with what it claims to test. The static symbol table has them as local `t` + # entries, this artifact is never stripped, and `No MG_Remote in the pull build` below already + # uses this spelling. The symbol count guards the remaining hole: a stripped library would + # make both greps fail for a third, silent reason. + - name: The verify library really carries the comparator + run: | + test -f "${BUILD_DIR}/libMobileGL.so" + defined=$(nm --defined-only "${BUILD_DIR}/libMobileGL.so" | wc -l) + if [ "${defined}" -lt 1000 ]; then + echo "::error::nm --defined-only sees only ${defined} symbols in ${BUILD_DIR}/libMobileGL.so - it looks stripped, so the two checks below could not have failed honestly" + exit 1 + fi + if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "MGPipeVerifyInputs"; then + echo "::error::libMobileGL.so defines no MGPipeVerifyInputs: -DMOBILEGL_PIPE_VERIFY=ON did not take, and every lane that consumes this artifact would run the comparator-free library and pass having compared nothing" + exit 1 + fi + # The per-verb entry point, under EITHER of its two names. P2 renames + # MGPipeFillForVerb to MGPipeValidateForVerb (the body becomes the tracker's walk and + # the fill is one of its five steps), so this check has to accept both or it goes red on + # the rename for a reason that has nothing to do with what it tests. What it tests is + # unchanged: that the library HAS a per-verb entry point compiled in. + if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -qE "MGPipeValidateForVerb|MGPipeFillForVerb"; then + echo "::error::libMobileGL.so defines neither MGPipeValidateForVerb nor MGPipeFillForVerb: there is no per-verb entry point in this artifact, so nothing fills the block the comparator compares" + exit 1 + fi + echo "libMobileGL.so defines MGPipeVerifyInputs and a per-verb entry point (${defined} defined symbols)" + + - name: Show ccache stats + if: always() + run: ccache --show-stats + + - name: Release superseded ccache entry + if: github.ref_name == github.event.repository.default_branch + env: + GH_TOKEN: ${{ github.token }} + CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + run: gh cache delete "${CACHE_KEY}" || true + + - name: Save ccache + if: github.ref_name == github.event.repository.default_branch + continue-on-error: true + uses: actions/cache/save@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + + - name: Package Linux verify runtime + run: | + mkdir -p ci-artifacts + mapfile -t SHARED_LIBS < <(find "${BUILD_DIR}" -type f \( -name '*.so' -o -name '*.so.*' \) -print | sort) + tar \ + --exclude='*/CMakeFiles' \ + --exclude='*.o' \ + --exclude='*.a' \ + --exclude='*.ninja*' \ + --exclude='build.ninja' \ + --exclude='cmake_install.cmake' \ + -czf ci-artifacts/mobilegl-linux-runtime-verify.tgz \ + "${BUILD_DIR}/CTestTestfile.cmake" \ + "${BUILD_DIR}/MobileGL/MG_Test" \ + "${BUILD_DIR}/MobileGL/MG_IntegrationTest" \ + "${SHARED_LIBS[@]}" + + - name: Upload Linux verify runtime + uses: actions/upload-artifact@v7 + with: + name: mobilegl-linux-runtime-verify + path: ci-artifacts/mobilegl-linux-runtime-verify.tgz + if-no-files-found: error + + # The verify lane itself, plus the two negative controls that keep it falsifiable. The controls + # are ALWAYS-ON steps, not a manual exercise: a gate that can only be shown to work by someone + # remembering to break it on purpose is a gate that has already stopped working. + integration-verify: + runs-on: ubuntu-latest + timeout-minutes: 180 + needs: build-linux-verify + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers + + - name: Download Linux verify runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-verify + path: . + + - name: Unpack Linux verify runtime + run: | + tar -xzf mobilegl-linux-runtime-verify.tgz + test -f build-verify/libMobileGL.so + + - name: Normalize CTest command paths + run: | + python - <<'PY' + from pathlib import Path + import re + + for path in Path('build-verify').rglob('CTestTestfile.cmake'): + text = path.read_text() + text = re.sub(r'"[^"]*/cmake-[^"]*/bin/cmake"', '"cmake"', text) + path.write_text(text) + PY + + - name: Integration scenarios under MOBILEGL_PIPE_VERIFY + working-directory: build-verify + # --no-tests=error is half the gate: the verify entries only exist when the library was + # configured with -DMOBILEGL_PIPE_VERIFY=ON, so a build that lost the option matches no + # tests and reds here instead of reporting a green run of nothing. The other half is + # PipeVerifyArmingScenario.Armed, which fails when the library never printed its arming + # line - the failure mode a bare `MOBILEGL_PIPE_VERIFY=1` cannot detect by itself. + # + # SCOPE, stated so nobody reads more into a green than is there: this is every integration + # ENTRY under the comparator, not every integration CONFIGURATION. The `integration` job + # runs a second, filtered pass with MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH=1 for the + # upload ring's staged-copy tier; that pass is 186 entries here and, at the 5-10x the + # comparator costs, is not affordable inside this job's budget. The tier is covered by + # `integration`, unverified, and P2 can take it once the comparator's cost is known. + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1" + MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1" + MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1" + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + if [ "${{ secrets.ACTIONS_STEP_DEBUG }}" = "true" ]; then + ctest -V -L integration-verify --no-tests=error + else + ctest --output-on-failure -L integration-verify --no-tests=error + fi + + # The push-only unit tests, on the verify runtime. + # + # WHY HERE AND NOT IN `test`. The entries themselves are registered in EVERY build - they + # have to be, or `ctest -N` would stop matching name-for-name between the pull and the push + # build (gate G2). What is push-only is what they assert about: MGPipeRenderStateSpans.cpp + # and PipeApply.cpp are appended to SOURCE_FILES inside the `if (MOBILEGL_PIPE_PUSH)` block, + # which is exactly how the pull build stays symbol-identical, so in a pull build each case + # opens with `#if !MOBILEGL_PIPE_PUSH GTEST_SKIP() << "push not compiled in"`. The `test` + # job therefore runs G6's chunk-table walk and G10's residual assertions as a column of + # skips: CI executes the NAMES and never one of the assertions. This job unpacks a build + # that compiled them, so it is the first place in CI where they actually run. + # + # This artifact already carries them - the packaging step above tars + # ${BUILD_DIR}/MobileGL/MG_Test whole - so the whole cost is the run, which is ~14 s for + # ~1490 entries. --no-tests=error, because a packaging change that stopped shipping the + # unit binaries would otherwise report a green run of nothing. + - name: Unit tests on the verify runtime (G6, G10) + working-directory: build-verify + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + + # The always-on P2 and P3a negative controls (G8, G10, G12), which are labelled + # integration-gpu and not integration-verify - they are about the handle key, the subsystem + # switch and the map-persistent counter, not about the comparator - so the lane above does + # not reach them. They are run HERE because this is the only CI job that unpacks a + # MOBILEGL_PIPE_PUSH build: every one of them reads a counter out of the library's summary + # line, and both the counters and their brackets are #if MOBILEGL_PIPE_PUSH, so in the pull + # `integration` job the entries exist (gate G2 requires the same names in both builds) but + # have nothing to assert. + # + # An arm whose subsystem has not landed on this tree SKIPS with the reason (never absent, + # never a green that asserted nothing), so this step is green through the P2 and P3a landing + # orders and starts asserting as each package arrives. + # + # The environment is the sibling step's, deliberately and in full: these entries run the + # same DirectVulkan binary through the same runner, so the three MOBILEGL_MAGMA_* fixes it + # needs apply here too, and a crash here has to leave a core for the same black-box flow. + # The step above is the only reason those lines exist in this job; a control that crashed + # without one would be the hardest failure in the job to diagnose. + # + # THE -R ALTERNATIVES ARE TEST-NAME PREFIXES, NOT LANE LABELS, and each one is deliberately + # the SHORTEST string that still selects only what it means to. `ResourceSubsystem` (not + # `ResourceSubsystemControl`) is what reaches the eight + # DirectGLES.ResourceSubsystemOn./Off.LargeArenaAdoptionScenario.* entries - the A/B lanes + # whose entire purpose is that the handle path and the legacy BufferBackendOps path must + # agree about an adopted store - as well as the two ResourceSubsystemControl. entries. + # `MapPersistentRoundtrip` is singular because LargeArenaAdoptionScenario's case is + # `AnAdoptionCostsExactlyOneMapPersistentRoundtrip`; the plural matched only the lane PREFIX + # of the other one. Both mistakes were silent: this is the only CI job that unpacks a push + # build, so an entry the filter misses is either never run under the P3a bits at all or runs + # only in the pull `integration` job, where a MOBILEGL_PIPE_PUSH value steers nothing + # (Config.h declares the field inside the push guard) and both arms are the same legacy path. + # A lane that cannot go red where it is installed is not a gate (ROADMAP.md:7). + # P4a ADDS THREE ALTERNATIVES, and each one is here because this is the only CI job that + # unpacks a push build: + # * `ObjectSubsystem` reaches the three DirectGLES.ObjectSubsystemControl. entries - the + # 0x1fff-vs-0x1ff A/B and the 0x9ff dependency refusal (G12). `ResourceSubsystem` does + # NOT match it: the two families are named apart on purpose, because they are different + # phases' switches and a filter that merged them would hide one behind the other. + # * `TextureParamsWithoutASamplerView` reaches G9's four cases, the scenario ROADMAP.md:20 + # names by hand. It runs in the ambient lanes, which the label already selects - but this + # step is where those cases run against a PUSH library, and G9's whole claim is about the + # push path. All four cases are green on the contract commit - including the one D-E3 + # expected to be red, for the reason the scenario's header records - so this row is green + # from the day it lands and goes red only if a reachability path stops syncing texture + # parameters at all, which is the coupling ARCHITECTURE.md:100 exists to remove. + # * `TextureUploadShape` is RECORDED, not gated (D-D4): it asserts that the two upload-shape + # counters could be read and that they agree, and prints the shape for MEASUREMENTS.md. It + # is in the filter so that the number is actually collected on every run - an unmeasured + # shape is not a recorded one - and because its own assertions can go red. + # As with the four before them, each alternative is the SHORTEST string that selects only what + # it means to. + - name: The handle-ABA, CSO, subsystem and texture-parameter controls (G8, G8b, G9, G10, G12) + working-directory: build-verify + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1" + MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1" + MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1" + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + ctest --output-on-failure -L integration-gpu \ + -R 'HandleRecycle|CsoContentAddressing|ResourceSubsystem|MapPersistentRoundtrip|ObjectSubsystem|TextureParamsWithoutASamplerView|TextureUploadShape' \ + --no-tests=error -j 4 + + # The arming lanes' logs, and ONLY those. Each lane shares one MOBILEGL_LOG_FILE_PATH and the + # library opens it fopen(path, "w"), so after an ambient lane of 400-odd processes the file + # holds the LAST one - grepping it would say nothing about the other 405 and would red a + # healthy lane whenever the last entry happened not to issue a verb (which is what the + # PoisonOmissionScenario parent, the last ambient entry, does by construction: it forks, + # execve()s and reads files). The DirectGLES.VerifyArming. / DirectVulkan.VerifyArming. + # entries are one process each on a log path nothing else writes, so this grep means exactly + # what it says. + # + # What it proves: arming is a property of (this library, this environment), and these two + # processes ran the same library with the same MOBILEGL_PIPE_VERIFY=1 as their ~400 ambient + # siblings. It is not, and cannot be, a per-process census - the shared log cannot support one. + # It catches the case ctest cannot: an arming entry that SKIPPED still reports green. + - name: The verify lanes armed the comparator + working-directory: build-verify + run: | + shopt -s nullglob + logs=(MobileGL/MG_IntegrationTest/pipe-verify-arming-*.log) + if [ ${#logs[@]} -lt 2 ]; then + echo "::error::found ${#logs[@]} pipe-verify-arming-*.log (expected one per backend). The VerifyArming. entries did not run, so nothing in this job establishes that the comparator was ever armed." + exit 1 + fi + for log in "${logs[@]}"; do + if ! grep -q "MGPipe: verify armed" "${log}"; then + echo "::error::${log} carries no arming line: that lane's process ran the whole scenario without the comparator, so every green entry beside it is green for no reason" + exit 1 + fi + done + echo "arming line present in all ${#logs[@]} arming-lane log(s)" + + # NEGATIVE CONTROL A (gate G4). The knob perturbs one field in the snapshot arm before the + # entry compare, so a working comparator must abort the run. This step passes when ctest + # FAILS - `if ctest ...; then error` - which is the only shape that can catch a comparator + # that silently compares nothing. + # + # The knob reaches the test process through the JOB environment: no ctest ENVIRONMENT + # property on the ambient Verify. entries names it (MG_IntegrationTest/CMakeLists.txt says + # so out loud), and a property entry would otherwise override this and the control would + # prove nothing. Same precedent as MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH in `integration`. + - name: Negative control A - a corrupted snapshot field must turn the lane red + working-directory: build-verify + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_PIPE_VERIFY_CORRUPT: GetRenderStateParameters + run: | + FILTER='DirectGLES\.Verify\..*ClearThenReadPixels' + # An empty selection would ALSO make ctest exit non-zero (--no-tests=error), and this + # step reads non-zero as "the control worked" - so the selection is counted first. A + # control that passes because it ran nothing is worse than no control. + matched=$(ctest -N -L integration-verify -R "${FILTER}" | grep -cE '^ *Test *#[0-9]+:') + if [ "${matched}" -lt 1 ]; then + echo "::error::negative control A selected ${matched} tests; its filter no longer matches anything" + exit 1 + fi + if ctest --output-on-failure -L integration-verify -R "${FILTER}" --no-tests=error; then + echo "::error::MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters left ${matched} verify entries GREEN. The comparator is not comparing, so every green entry above is green for no reason." + exit 1 + fi + echo "the corrupted field turned ${matched} selected entries red, as it must" + + # NEGATIVE CONTROL B (gate G5). The omission skips the STAMP of one field for one verb while + # still copying its value - indistinguishable from a fill row nobody wrote - so the poison + # must abort the glGenerateMipmap. Again: this step passes when ctest fails. + # + # The entry it targets is PoisonOmissionScenario.WithoutOmissionCompletes, which is green in + # the ambient lane above and is the ONLY integration entry in the tree that calls + # glGenerateMipmap at all. It deliberately does not skip itself when the knob is set, exactly + # so that this control has something to turn red. + - name: Negative control B - an omitted fill point must turn the lane red on that verb + working-directory: build-verify + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_PIPE_POISON_OMIT: GenerateMipmap:GetActiveTextureUnit + run: | + FILTER='DirectGLES\.Verify\.PoisonOmissionScenario\.WithoutOmissionCompletes' + matched=$(ctest -N -L integration-verify -R "${FILTER}" | grep -cE '^ *Test *#[0-9]+:') + if [ "${matched}" -lt 1 ]; then + echo "::error::negative control B selected ${matched} tests; its filter no longer matches anything" + exit 1 + fi + if ctest --output-on-failure -L integration-verify -R "${FILTER}" --no-tests=error; then + echo "::error::MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit left the verify lane GREEN. The per-verb poison is not armed, so a forgotten fill row would ship silently." + exit 1 + fi + echo "the omitted fill point turned the lane red, as it must" + + - name: Upload verify lane logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-verify-logs + path: build-verify/MobileGL/MG_IntegrationTest/pipe-*.log* + if-no-files-found: warn + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: integration-verify-core-dumps + path: /tmp/core.* + if-no-files-found: ignore + + # P5's split build. The verify pair cloned, because the problem is the same one: a third + # configuration of the same sources whose whole value depends on the option having taken. + # + # IT CANNOT RIDE ON build-linux'S ARTIFACT. build-linux passes no -DMOBILEGL_PIPE_PUSH, so it is + # a PULL build; MOBILEGL_BUILD_DISAGGREGATED implies MOBILEGL_PIPE_PUSH (the split path decodes + # into the MGPipe applier and PIPE_PUSH is what compiles the applier), so the split arm needs a + # build of its own exactly as the verify arm does. + # + # THE `nm` STEP IS THE POINT OF THIS JOB. In a build without the option, MG_Remote is not + # compiled at all and MOBILEGL_TRANSPORT's parser does not exist - so the variable is accepted by + # the environment and silently ignored (CONTRACT-P5 5). Every downstream lane would then run + # monolith and go green under a name that says split. CMake will not complain about a typo'd -D, + # so the build-level assertion is the only guard, and its absence is precisely what would make + # the whole split arm meaningless. + build-linux-split: + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + actions: write + contents: read + env: + BUILD_DIR: build-split + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPRESS: "true" + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 4G + CCACHE_NOHASHDIR: "true" + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 32 + + - name: Checkout repo + uses: actions/checkout@v6 + with: + # recursive, and load-bearing here beyond the usual: MOBILEGL_BUILD_DISAGGREGATED + # SHADOWS ITSELF BACK TO OFF when 3rdparty/flatbuffers/include is missing + # (CMakeLists.txt:471-486, a normal variable rather than a cache force, deliberately). + # A shallow checkout would therefore configure cleanly, build a monolith library, and + # be caught only by the nm step below. + submodules: recursive + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Restore ccache + uses: actions/cache/restore@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + restore-keys: | + ${{ runner.os }}-test-${{ github.job }}-ccache- + + - name: Prepare Vulkan SDK + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Update glslang external sources + working-directory: 3rdparty/glslang + run: python update_glslang_sources.py + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build + + - name: Show installed toolchain + run: | + ccache --version + clang-20 --version + clang++-20 --version + dpkg -l 'libc++*' 'libegl*' 'libgles*' 'mesa*' 'vulkan*' || true + + - name: Configure CMake + # Release/INFO like the shipped build, for the same reason build-linux-verify gives. + # INFO specifically matters here: ConfigLoader logs the resolved transport at INFO and + # that line is what run_trace_case.cmake and the integration-split lane read back as + # proof the transport resolved in THIS process. + # + # _INPROC implies _DISAGGREGATED implies _PIPE_PUSH; all three are passed anyway, because + # an implication that is asserted in two places is an implication nobody has to remember. + run: | + cmake -S . -B "${BUILD_DIR}" -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 \ + -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=ON \ + -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=ON \ + -DMOBILEGL_ITEST_VK_ICD=/usr/share/vulkan/icd.d/lvp_icd.json \ + -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_PIPE_PUSH=ON \ + -DMOBILEGL_BUILD_DISAGGREGATED=ON \ + -DMOBILEGL_BUILD_DISAGGREGATED_INPROC=ON \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + + - name: Build + run: cmake --build "${BUILD_DIR}" --parallel "$(nproc)" + + # `nm` and NOT `nm -D`, for the reason build-linux-verify spells out: everything under + # MG_Remote is a plain namespace symbol in a CXX_VISIBILITY_PRESET=hidden Release build and + # none of it reaches the dynamic table. The symbol count guards the remaining hole - a + # stripped library would make the grep fail for a third, silent reason. + # + # The mirror of this assertion already exists and is the G1 control: monolith-symbol-report + # asserts that a -DMOBILEGL_BUILD_DISAGGREGATED=OFF library defines NO MG_Remote symbol. The + # two together are ARCHITECTURE.md:524's surviving byte-level equality, in both directions. + - name: The split library really carries MG_Remote + run: | + test -f "${BUILD_DIR}/libMobileGL.so" + defined=$(nm --defined-only "${BUILD_DIR}/libMobileGL.so" | wc -l) + if [ "${defined}" -lt 1000 ]; then + echo "::error::nm --defined-only sees only ${defined} symbols in ${BUILD_DIR}/libMobileGL.so - it looks stripped, so the checks below could not have failed honestly" + exit 1 + fi + remote=$(nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -c -i "MG_Remote" || true) + if [ "${remote}" -lt 1 ]; then + echo "::error::libMobileGL.so defines no MG_Remote symbol: -DMOBILEGL_BUILD_DISAGGREGATED=ON did not take (a typo'd -D is not a CMake error, and the option shadows itself OFF when 3rdparty/flatbuffers/include is missing). Every lane that consumes this artifact would run MONOLITH while claiming to run split, because the MOBILEGL_TRANSPORT parser does not exist in such a build and the variable is accepted and ignored." + exit 1 + fi + # The transport parser itself, which is the symbol the runtime evidence depends on. + if ! nm --defined-only "${BUILD_DIR}/libMobileGL.so" | grep -q "MGPipeApply"; then + echo "::error::libMobileGL.so defines no MGPipeApply* entry point, so MOBILEGL_PIPE_PUSH did not take either and there is no applier for the split path to decode into" + exit 1 + fi + echo "libMobileGL.so defines ${remote} MG_Remote symbol(s) and the MGPipe applier (${defined} defined symbols)" + + - name: Show ccache stats + if: always() + run: ccache --show-stats + + - name: Release superseded ccache entry + if: github.ref_name == github.event.repository.default_branch + env: + GH_TOKEN: ${{ github.token }} + CACHE_KEY: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + run: gh cache delete "${CACHE_KEY}" || true + + - name: Save ccache + if: github.ref_name == github.event.repository.default_branch + continue-on-error: true + uses: actions/cache/save@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + + - name: Package Linux split runtime + run: | + mkdir -p ci-artifacts + mapfile -t SHARED_LIBS < <(find "${BUILD_DIR}" -type f \( -name '*.so' -o -name '*.so.*' \) -print | sort) + tar \ + --exclude='*/CMakeFiles' \ + --exclude='*.o' \ + --exclude='*.a' \ + --exclude='*.ninja*' \ + --exclude='build.ninja' \ + --exclude='cmake_install.cmake' \ + -czf ci-artifacts/mobilegl-linux-runtime-split.tgz \ + "${BUILD_DIR}/CTestTestfile.cmake" \ + "${BUILD_DIR}/MobileGL/MG_Test" \ + "${BUILD_DIR}/MobileGL/MG_IntegrationTest" \ + "${SHARED_LIBS[@]}" + + - name: Upload Linux split runtime + uses: actions/upload-artifact@v7 + with: + name: mobilegl-linux-runtime-split + path: ci-artifacts/mobilegl-linux-runtime-split.tgz + if-no-files-found: error + + # The split lane itself: the three-arm shape ARCHITECTURE.md:521 asks for verbatim, plus the + # first real run of the five MG_Test/Wire suites, plus P5's two exit-gate negative controls. + integration-split: + runs-on: ubuntu-latest + timeout-minutes: 120 + needs: build-linux-split + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers + + - name: Download Linux split runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-split + path: . + + - name: Unpack Linux split runtime + run: | + tar -xzf mobilegl-linux-runtime-split.tgz + test -f build-split/libMobileGL.so + + - name: Normalize CTest command paths + run: | + python - <<'PY' + from pathlib import Path + import re + + for path in Path('build-split').rglob('CTestTestfile.cmake'): + text = path.read_text() + text = re.sub(r'"[^"]*/cmake-[^"]*/bin/cmake"', '"cmake"', text) + path.write_text(text) + PY + + # THE FIRST REAL RUN OF MG_Test/Wire. Those five suites - Framing, Ring, InProcessTransport, + # ProtocolSmoke and FdPassing - are registered only under MOBILEGL_BUILD_DISAGGREGATED + # (MG_Test/CMakeLists.txt), and before this job existed NO cmake invocation anywhere in this + # workflow passed that option. They had never been compiled by CI, let alone run. + - name: Unit tests on the split runtime + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + + # --no-tests=error is half the gate, exactly as in integration-verify: the integration-split + # entries exist only when the library was configured with -DMOBILEGL_BUILD_DISAGGREGATED=ON, + # so a build that lost the option matches nothing and reds here instead of reporting a green + # run of nothing. The other half is the transport-resolution check below. + # + # An entry whose owning package (c1 client, s1 session, v1 server) has not landed SKIPS with + # the reason and never goes green - MG_IntegrationTest/CMakeLists.txt probes MG_Remote for + # c0's signature stubs and disarms the lane while any remain. + - name: Split scenarios under MOBILEGL_TRANSPORT=inproc + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + ctest --output-on-failure -L integration-split --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/split-baseline.xml" + python3 ../scripts/ci/junit_tally.py "${RUNNER_TEMP}/split-baseline.xml" --require-split-ran + + # ID-65 supersedes broad inproc status parity: class-C aborts and named wrong-answer + # debts are recorded. Monolith, integration-split and the controls remain hard gates. + # + # The entries that name MOBILEGL_TRANSPORT in their OWN property (the Split. lanes) keep + # their value in both passes, which is correct: they are the split family in both arms and + # the comparison is about the other 1100. + - name: The same integration entries under monolith and under inproc + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_MAGMA_FIX_ITERATIONRP_SUBGROUP_SCRATCH: "1" + MOBILEGL_MAGMA_DERIVE_NUM_SUBGROUPS: "1" + MOBILEGL_MAGMA_ITERATIONRP_FIX_BARRIER: "1" + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + count=$(ctest -N -L integration-gpu | grep -cE '^ *Test *#[0-9]+:') + if [ "${count}" -lt 1 ]; then + echo "::error::the split runtime registers ${count} integration-gpu entries" + exit 1 + fi + echo "integration-gpu entries in the split build: ${count}" + MOBILEGL_TRANSPORT=monolith ctest --output-on-failure -L integration-gpu --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/arm-monolith.xml" + # ID-65: broad inproc is a recorded debt census. Reduced split + controls gate below. + inproc_rc=0 + MOBILEGL_TRANSPORT=inproc ctest --output-on-failure -L integration-gpu --no-tests=error -j 4 --output-junit "${RUNNER_TEMP}/arm-inproc.xml" || inproc_rc=$? + python3 ../scripts/ci/census_junit.py "${RUNNER_TEMP}/arm-inproc.xml" "${inproc_rc}" >> "${GITHUB_STEP_SUMMARY}" + # Retain the per-name status delta as evidence, not as the reduced-path gate. + python3 - "${RUNNER_TEMP}/arm-monolith.xml" "${RUNNER_TEMP}/arm-inproc.xml" <<'PY' + import sys, xml.etree.ElementTree as ET + def rows(path): + out = {} + for case in ET.parse(path).getroot().iter('testcase'): + status = 'passed' + if case.find('failure') is not None or case.find('error') is not None: + status = 'failed' + elif case.find('skipped') is not None or case.get('status') in ('notrun', 'disabled'): + status = 'skipped' + out[case.get('name')] = status + return out + a, b = rows(sys.argv[1]), rows(sys.argv[2]) + diff = sorted(set(a) ^ set(b)) + sorted(n for n in set(a) & set(b) if a[n] != b[n]) + if diff: + for name in diff[:40]: + print(f"{name}: monolith={a.get(name, '')} inproc={b.get(name, '')}") + print(f"Recorded ID-65 census: {len(diff)} name/status differences; not a parity gate") + else: + print(f"the two arms agree on all {len(a)} entries, name and status") + PY + + # THE RUNTIME HALF OF "THIS IS REALLY A SPLIT BUILD". The build-level nm check in + # build-linux-split proves the library CARRIES MG_Remote; this proves the transport + # RESOLVED in a process of this lane. ConfigLoader::InitTransport logs one INFO line when it + # selects InProcess, and the DirectGLES.Split.PersistentMapArm. entry retains its private + # counting log (all Split entries now have private paths; nothing else writes this file). + # The line is written during bring-up, before any scenario decides to skip, so this + # check is live from the day the lanes land rather than from the day they stop skipping. + - name: The split lane really resolved the transport + working-directory: build-split + run: | + log=MobileGL/MG_IntegrationTest/persistent-map-arm-split-DirectGLES.log + if [ ! -f "${log}" ]; then + echo "::error::${log} does not exist: the DirectGLES.Split.PersistentMapArm. entry never ran, so nothing in this job establishes that MOBILEGL_TRANSPORT ever resolved to inproc in a live process" + exit 1 + fi + # THE DISTINCTIVE PART OF THE INFO LINE, not the bare KEY=VALUE (review finding M-5): + # ConfigLoader logs `Config: Accepted env variable: MOBILEGL_TRANSPORT=inproc` for the + # env dump too, unconditionally and in a PULL build, and at DEBUG that line is live. + if ! grep -q "MOBILEGL_TRANSPORT=inproc - the MGPipe record stream" "${log}"; then + echo "::error::${log} carries no transport-resolution line. ConfigLoader::InitTransport logs it at INFO when it selects InProcess, and that code exists only in a MOBILEGL_BUILD_DISAGGREGATED build - so this lane ran a monolith library while claiming to be the split lane. (A bare MOBILEGL_TRANSPORT=inproc substring is NOT accepted: the env dump prints one in every build.)" + exit 1 + fi + echo "the split lane resolved MOBILEGL_TRANSPORT=inproc" + + # EXIT GATE E1's NEGATIVE CONTROL and EXIT GATE E3(a)'s, in one step because they have the + # same two-state shape and the same reason for it. + # + # Both controls turn a knob that MUST make a split scenario red: MOBILEGL_IPC_VERB_BARRIER=0 + # removes the lockstep fence R-1 rests on, and MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0 turns the + # persistent-map push off. A control of the verify lane's shape - "this step passes when + # ctest FAILS" - cannot be written yet, because while packages c1/s1/v1 are landing the + # Split entries SKIP and ctest reports green whatever the knob says, so an unconditional + # control would be red for the whole of P5 for a reason that is not a defect. + # + # So the expected state is DERIVED FROM BEHAVIOUR rather than assumed. The first version read + # MGITEST_REMOTE_CLIENT_PRESENT out of the generated *_tests.cmake, which was a restatement of + # the CMake source probe review finding M-1 falsified; the arming condition is a runtime fact + # inside each test process (MG_Config::Transport, ClientSession::Active() and + # ImplementedVerbCount(), read by Harness/SplitRuntimePeek), so the only honest way to ask it + # from a shell is to look at what the entries DID. When entries passed, the controls MUST + # fire; when every one of them skipped, the step says so loudly and does not pretend. + # + # THE BODY OF THIS STEP IS scripts/ci/split_negative_controls.sh, and the move is the point + # rather than tidiness. A `run:` block executes nowhere but on a runner, so these lines were + # unreviewable and untestable: when the wave-1 cross-family review said they were broken, + # CONFIRMING it needed a hand-made copy of them (wave1-codex-verify.md 8), and a copy is not + # the thing. scripts/ci/control_smoke_test.sh now drives the very file this step runs. + # + # What that smoke test pins, and what ID-46 finding 8 found missing: each control asserts its + # OWN failure reason. A non-zero ctest exit used to be enough, so a timeout, a setup abort or + # any unrelated assertion printed "turned N selected entries red, as it must" and this step + # went green. The arming run's `|| true` had the matching defect - it counted a case that ran + # and FAILED as evidence the lane was live, so the controls could be measured against a + # baseline that was already red. + - name: Negative controls - the verb barrier and the persistent-map push must be load-bearing + if: ${{ !cancelled() }} + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + CONTROL_TMPDIR: ${{ runner.temp }} + run: bash "${GITHUB_WORKSPACE}/scripts/ci/split_negative_controls.sh" + + - name: Upload split lane logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-split-logs + path: | + build-split/MobileGL/MG_IntegrationTest/*.log* + build-split/MobileGL/MG_IntegrationTest/split-logs/*.log + ${{ runner.temp }}/arm-inproc.xml + ${{ runner.temp }}/arm-monolith.xml + if-no-files-found: warn + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: integration-split-core-dumps + path: /tmp/core.* + if-no-files-found: ignore + + # P5c gt (CONTRACT-P5C §6): the strict twin of integration-split. The role guards (the two + # layers of Fatal{RoleViolation, ...}) are COMPILED into every split build - there is no + # knob to arm them - so "guards armed" below means MOBILEGL_IPC_STRICT_ERRORS=1, the knob + # that promotes a BARRIER-PULLED residual read from counted debt to a named abort. + # + # TWO ASSERTIONS, NOT ONE, because the two halves of "strict is armed" are different + # statements: + # + # * the unit label must stay GREEN under it. A unit case that newly aborts under strict + # is a value-class read that crept back in (P5c rv's exit line is value-class = 0), and + # this step is what says so. FieldOwnershipTest owns the strict arms' behaviour cases; + # + # * the integration-split scenarios must all ABORT under it, BY DESIGN: the remaining + # residual pulls are the pinned object-class rows (GetFramebufferBindingSlot & co., + # P3b/P4b/P7's), every drawing scenario reads one, and strict turns the first read into + # Fatal{UnmigratedPipeInput, ...} [BARRIER-PULLED, MOBILEGL_IPC_STRICT_ERRORS=1, ...]. + # A lane that "passed" a scenario here would mean strict never armed - the same + # two-state shape as the E1/E3(a) negative controls, so the step asserts the abort AND + # greps the entry's private split-log for the strict marker. This half flips meaning + # when the object-class rows retire (P7): the scenarios then PASS under strict and the + # step is rewritten as an ordinary green lane. + integration-split-strict: + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: build-linux-split + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1 libegl-mesa0 libgles2 libgl1-mesa-dri mesa-vulkan-drivers + + - name: Download Linux split runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-split + path: . + + - name: Unpack Linux split runtime + run: | + tar -xzf mobilegl-linux-runtime-split.tgz + test -f build-split/libMobileGL.so + + - name: Normalize CTest command paths + run: | + python - <<'PY' + from pathlib import Path + import re + + for path in Path('build-split').rglob('CTestTestfile.cmake'): + text = path.read_text() + text = re.sub(r'"[^"]*/cmake-[^"]*/bin/cmake"', '"cmake"', text) + path.write_text(text) + PY + + # The green half: no unit case may pull harder under strict. This includes the + # RemoteGuards suite (the layer-1/layer-2 Fatal drives) and FieldOwnershipTest's strict + # arms, so the guards are provably armed in the lane that gates on them. + - name: Unit tests on the split runtime under MOBILEGL_IPC_STRICT_ERRORS=1 + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_IPC_STRICT_ERRORS: "1" + run: ctest --output-on-failure -L unit --no-tests=error -j "$(nproc)" + + # ---- P5e (ra), MG_Remote/CONTRACT-P5E.md §7: ONE STEP, TWO ARMS, AND THE TREE PICKS ---- + # + # This lane was written as an EXPECTED-RED control: under strict every integration-split + # entry aborts on its first BARRIER-PULLED read, so the step gated on the run failing and + # on the strict marker being the reason. P5e turns it into a HARD GREEN GATE - once the + # client runs ahead, an unbarriered record's pull is a Fatal whatever this knob says + # (§3.3), so what strict adds is only the BARRIERED rows' pulls, and those are a short, + # named list. A revert of any handle arm in vi/sb/pg/tx2/fb then goes red HERE, by field + # and verb, instead of in a picture nobody diffs. + # + # THE ARM IS CHOSEN BY THE TREE, NOT BY A SECOND EDIT. kMGPipeP5eRunAheadReady in + # MG_Backend/Init.cpp is the one constant the integration commit flips, and it is exactly + # the fact this lane's expectation depends on: with it false no server publishes + # kCapRunAheadApply, every record is barriered, every scenario still aborts on its first + # pull and the expected-red control is the honest gate. Reading it here means the lane + # changes meaning in the same commit the runtime does - a lane whose flip is a separate + # human step is a lane that is wrong for as long as that step is outstanding. + - name: Integration-split under strict - expected-red before the P5e flip, hard green after + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_IPC_STRICT_ERRORS: "1" + MOBILEGL_TRANSPORT: "inproc" + run: | + ulimit -c unlimited + ready=$(grep -oE 'kMGPipeP5eRunAheadReady = (true|false)' "${GITHUB_WORKSPACE}/MobileGL/MG_Backend/Init.cpp" | head -1 | awk '{print $3}') + echo "kMGPipeP5eRunAheadReady = ${ready}" + helper="${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py" + expected="${GITHUB_WORKSPACE}/MobileGL/MG_IntegrationTest/Harness/strict-expected-markers.txt" + + # THE LOG SET COMES FROM ctest, NOT FROM A DIRECTORY (P5e gl, ID-119). This step used to + # grep MobileGL/MG_IntegrationTest/split-logs/, which holds 90 of the lane's entries. + # The 26 it could not see were the F1. readback block, both NamedBlit pairs, the four + # Ct. entries and PersistentMapArm - i.e. PRECISELY the readback population the + # allowlist is about, plus the only DirectVulkan entries in the lane. A directory is a + # guess about where the lane put its logs; `--show-only=json-v1` is the lane saying so. + ctest -L integration-split --show-only=json-v1 > "${RUNNER_TEMP}/lane.json" + + # Stale logs from an earlier job would be read as this run's evidence: an entry that + # never starts leaves the previous file in place, and "no marker" is exactly what a + # lane that never ran looks like. + python3 - "${RUNNER_TEMP}/lane.json" "${helper}" <<'PY' + import importlib.util, json, os, sys + spec = importlib.util.spec_from_file_location("slp", sys.argv[2]) + slp = importlib.util.module_from_spec(spec); spec.loader.exec_module(slp) + logs = slp.marker_log_paths(json.load(open(sys.argv[1]))) + for path in set(logs.values()): + try: + os.unlink(path) + except OSError: + pass + print(f"reset {len(set(logs.values()))} private log(s) across {len(logs)} entries") + PY + + rc=0 + ctest -L integration-split --no-tests=error -j 4 > "${RUNNER_TEMP}/strict-split.txt" 2>&1 || rc=$? + tail -5 "${RUNNER_TEMP}/strict-split.txt" + echo "strict run: rc=${rc}" + + python3 "${GITHUB_WORKSPACE}/scripts/gen_pipe_field_ownership.py" --print-admitted \ + > "${RUNNER_TEMP}/allowed.txt" + echo "§7 allowlist (derived): $(wc -l < "${RUNNER_TEMP}/allowed.txt") admitted pair(s)" + + if [ "${ready}" != "true" ]; then + # ---- the pre-flip arm ---- + if [ "${rc}" -eq 0 ]; then + echo "::error::integration-split under MOBILEGL_IPC_STRICT_ERRORS=1 went GREEN while kMGPipeP5eRunAheadReady is false - the object-class residual pulls (P3b/P4b/P7) are still pinned, so strict must abort every drawing scenario. If P5e has just landed, flip that constant in the same commit." + exit 1 + fi + # The census, and the ADMITTED half of the ratchet, run in this arm too: the Fatal + # half is expected here (it is what "red by design" MEANS), but a debt that appeared + # or vanished on the admitted side is news whichever arm the tree is in. + python3 "${helper}" markers "${RUNNER_TEMP}/lane.json" '.' \ + "${RUNNER_TEMP}/allowed.txt" "${expected}" + echo "integration-split under strict is red by design, and its marker census above is the evidence" + exit 0 + fi + + # ---- the post-flip arm: HARD GREEN, two-sided ---- + # + # EVERY ENTRY MUST COMPLETE, and the census is what says why when one does not: an abort + # is either an unbarriered pull (§3.3's unconditional Fatal, a missed migration) or a + # barriered pull nothing admits, and both come out of the census by field and verb. + if [ "${rc}" -ne 0 ]; then + echo "::error::integration-split under MOBILEGL_IPC_STRICT_ERRORS=1 is RED on a run-ahead server. CONTRACT-P5E §7 makes this lane a hard green gate; the census below names the field and the verb of every entry that aborted." + python3 "${helper}" markers "${RUNNER_TEMP}/lane.json" '.' \ + "${RUNNER_TEMP}/allowed.txt" "${expected}" || true + exit 1 + fi + + # THE ADMITTED MARKERS COME FROM THE GENERATOR, NOT FROM THIS FILE (ID-116). + # + # A hand-kept copy of §7 lived here for one round and was wrong in BOTH directions at + # once: it omitted GetFramebufferBindingSlot@ReadPixels - 21 entries, the largest + # survivor in the lane - and it carried GetTextureObject@CopyImageSubData, which + # nothing made safe, because resource_copy_region is kWaitNone. Neither error is + # visible by reading the list; both are obvious the moment the list is DERIVED. + # + # The rule is ID-84 said in tables that already exist (ID-116, refined by ID-125): a + # @ pull is admitted iff the field's ownership row is BARRIER_PULLED, the + # verb has a stamp row, the field is inside the verb class's may-read mask, and EITHER + # the verb's wire op is statically barriered (kMGPipeWaitClasses != kWaitNone, i.e. the + # client really is parked behind the record) OR the field's retiring phase does not + # name P5e. The second disjunct is the honest reading of ID-84: this comparison only + # ever sees pulls on records the server stamped BARRIERED - an unbarriered one aborts + # before any of this - so the pull is legal by construction and the only question left + # is whether THIS phase still owes the migration. Without it the lane would go red on + # GetTransformFeedbackProgram@DrawArrays, a row CONTRACT-P5E §5.7 rules out of P5e + # entirely and no package here is allowed to touch. + # `--print-admitted` prints exactly the table MGPipeBarrierPullAdmitted() answers + # from, so this shell and the C++ cannot drift: retiring a verb's wait class narrows + # the lane with no edit here, and a new BARRIER_PULLED row widens it only on verbs + # that wait. + # THE RATCHET, BOTH SIDES (ID-119). `no-fatal` is what makes this arm the hard gate: + # - any Fatal marker fails; + # - an ADMITTED marker outside the generated allowlist fails, which can only mean the + # committed PipeFieldOwnership.inc and the generator disagree; + # - a pair that appears and is not in strict-expected-markers.txt fails: it is a debt + # somebody now owes and it gets written down with the phase that retires it; + # - AND a pair in that file that no longer appears fails, so the lane CANNOT ROT + # GREEN. Without that half, a retired debt leaves its row behind as a permanent + # licence and the next regression to re-introduce the read reads as "expected". + # ADMITTED-ESCALATED markers are checked against the expected set but NOT against the + # allowlist: they are a runtime fact about the record's payload (an open XFB span, a + # draw carrying client vertex arrays) that no table indexed by (field, verb) can carry, + # and widening the static list to hold them would forgive the ordinary draw path too. + python3 "${helper}" markers "${RUNNER_TEMP}/lane.json" '.' \ + "${RUNNER_TEMP}/allowed.txt" "${expected}" no-fatal + + # `rsp`, AND THE PIN THAT REPLACED A VACUOUS ONE (ID-119). What stood here was a grep + # for `rsp=` over split-logs/ - a counter no entry in that directory emits, because + # PipeStats is off unless a lane asks for it - testing a sentence that was true of every + # possible implementation: CountBarrierPull's unbarriered arm is [[noreturn]] and runs + # BEFORE ++g_residualPulls, so an unbarriered pull can never reach the counter whatever + # the code does. It has been replaced by an assertion inside a lane entry that actually + # counts: DirectGLES.Split.StrictArming., which runs with MOBILEGL_PIPE_STATS=1, + # MOBILEGL_PIPE_STATS_PERIOD=1 and a private log path of its own (a whole-lane env flip + # races under -j, since the library opens that path "w"), and asserts + # vbs > 0 - the server stamped a verb boundary on a DRAWING inproc entry, which is + # ID-115's positive control: everything strict checks is downstream of + # that stamp, and before it existed "the lane is green" and "strict was + # never armed" were the same observation; + # rsp == 0 or rsp >= draws - the checkable shape of the debt. While the draw path's + # pull is unretired every draw pulls, so rsp scales with draws; the + # retirement is visible as rsp ceasing to scale. That is what the device + # measured (rsp ~= the draw count under inproc, 0 under monolith). + # So the assertion lives where the counter is, and this step only has to insist the + # entry RAN - a positive control that can be skipped away is not one. + if ! grep -q 'DirectGLES\.Split\.StrictArming\.' "${RUNNER_TEMP}/strict-split.txt"; then + echo "::error::the strict lane did not run DirectGLES.Split.StrictArming., so nothing in this run proves the server ever stamped a verb boundary. A green lane without it is indistinguishable from a lane where strict was never armed (ID-115)." + exit 1 + fi + echo "integration-split under strict is GREEN, two-sided, with the arming control present" + + # ---- P5e (gl), CONTRACT-P5E §7: MAGMA'S OWN LANE, EXPECTED RED ----------------------- + # + # The two DirectVulkan.Split.NamedBlit entries abort on GetFramebufferBindingSlot@Clear + # (VulkanRenderer.cpp:7648). That field retires in P5e ON ESPRYT and in P7 ON MAGMA, and + # FieldOwnership.def carries ONE retiring-phase string for both - so admitting the pair to + # keep them in the Espryt lane would forgive an Espryt Clear reading the frontend's binding + # slot again, which is exactly the regression fb's handle arm exists to prevent. Splitting + # the lane costs two entries; admitting the row would cost the gate. + # + # THE LABEL IS `integration-magma-split` AND THE SPELLING MATTERS: `ctest -L` is a REGEX, so + # a label named `integration-split-magma` is still matched by `-L integration-split` and the + # entries would never have left. Measured before the rename. + # + # IT IS ASSERTED RED, WITH ITS MARKER. "Magma is red" must not be allowed to decay into + # "Magma did not run": the step fails both when the run goes green (P7 landed early, or the + # entries stopped drawing) and when it goes red without the named abort in a private log. + - name: Integration-magma-split under strict - expected red until P7 (CONTRACT-P5E section 7) + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_IPC_STRICT_ERRORS: "1" + MOBILEGL_TRANSPORT: "inproc" + run: | + ulimit -c unlimited + rc=0 + ctest -L integration-magma-split --no-tests=error -j 2 > "${RUNNER_TEMP}/strict-magma.txt" 2>&1 || rc=$? + tail -5 "${RUNNER_TEMP}/strict-magma.txt" + if [ "${rc}" -eq 0 ]; then + echo "::error::integration-magma-split went GREEN under strict. Magma is in lockstep for the whole of P5e (ID-90) and GetFramebufferBindingSlot retires there in P7, so these entries are expected to abort. If P7 has landed, delete this step and fold the entries back into integration-split." + exit 1 + fi + marker='Fatal{UnmigratedPipeInput, "GetFramebufferBindingSlot@Clear"}' + if ! grep -rqF "${marker}" MobileGL/MG_IntegrationTest/p5b-DirectVulkan-*.log 2>/dev/null; then + echo "::error::integration-magma-split is red but no private log carries ${marker} - the lane failed for some OTHER reason, which is a defect rather than the expected-red control" + grep -rhoE '(Fatal|Admitted)\{[A-Za-z]*, "[^"]*"\}' MobileGL/MG_IntegrationTest/p5b-DirectVulkan-*.log 2>/dev/null | sort | uniq -c | sort -rn | head + exit 1 + fi + echo "integration-magma-split is red by design on ${marker}" + + # ---- P5e (ra2), ID-134 / ID-82: THE CLIENT-ARRAY LANE, EXPECTED RED UNDER RUN-AHEAD ---- + # + # The two DirectGLES.Split.ClientVertexArrayScenario entries draw from a vertex array in + # the application's OWN memory. The server uploads those bytes by dereferencing a raw + # client pointer per draw, which is exactly the read rule F forbids once the client stops + # waiting - so ID-82 makes the draw a NAMED REFUSAL under run-ahead and gives the staging + # form to P8. Aborting is therefore the DESIGNED behaviour, and a lane that demanded these + # entries green would be demanding that the refusal not fire. + # + # THE LABEL, AND WHY IT IS NOT `integration-split-clientarrays` (ID-131): `ctest -L` is a + # REGEX. That spelling is matched by `-L integration-split` as a substring, so the entries + # would still be in the lane they were moved out of while every listing showed them + # renamed. Counted, not assumed: `-L integration-split` went 181 -> 179 and + # `-L integration-gpu` 1359 -> 1357 when this label landed. + # + # IT BRANCHES ON WHETHER RUN-AHEAD IS ACTUALLY ARMED, and that is not hedging - it is the + # only formulation that is a real assertion on BOTH heads. kMGPipeP5eRunAheadReady is a + # BUILD constant (MG_Backend/Init.cpp): on a head where it is false no environment can arm + # run-ahead, the refusal is unreachable, and these entries are green - so a step that + # simply demanded red would fail for the one reason that is not a defect. The client says + # which world it is in, in its own log line, and the step reads that and then demands the + # matching outcome. When the flip lands for good, delete the green branch. + - name: Integration-clientarrays-split - expected red under run-ahead (ID-82 / ID-134) + working-directory: build-split + env: + MOBILEGL_ITEST_REQUIRE_GPU: "1" + MOBILEGL_TRANSPORT: "inproc" + run: | + ulimit -c unlimited + logs=MobileGL/MG_IntegrationTest/split-logs + rm -f "${logs}"/*ClientVertexArrayScenario*.log 2>/dev/null || true + rc=0 + ctest -L integration-clientarrays-split --no-tests=error -j 1 \ + > "${RUNNER_TEMP}/clientarrays.txt" 2>&1 || rc=$? + tail -5 "${RUNNER_TEMP}/clientarrays.txt" + if ! grep -rqF "run-ahead ARMED" "${logs}"/*ClientVertexArrayScenario*.log 2>/dev/null; then + # kMGPipeP5eRunAheadReady is false on this head: the client is lockstep, vi's arm is + # alive and these entries are the positive control they were registered as. + if [ "${rc}" -ne 0 ]; then + echo "::error::integration-clientarrays-split is red WITHOUT run-ahead armed. Under lockstep the client-memory array path is legal and vi keeps it working, so this is a real regression in that arm and not ID-82's refusal." + exit 1 + fi + echo "integration-clientarrays-split is green: run-ahead is not armed on this build, so ID-82's refusal is unreachable" + exit 0 + fi + if [ "${rc}" -eq 0 ]; then + echo "::error::integration-clientarrays-split went GREEN with run-ahead ARMED. ID-82 refuses a client-memory vertex array under run-ahead by name, so a green run means the refusal did not fire and the server dereferenced a client pointer from an unbarriered record. If P8 has landed the staged form, delete this step and fold the entries back into integration-split." + exit 1 + fi + marker='Fatal{UnmigratedVerb, "' + if ! grep -rhoE 'Fatal\{UnmigratedVerb, "(Multi)?DrawArrays\+CLIENT_ARRAYS"\}' \ + "${logs}"/*ClientVertexArrayScenario*.log 2>/dev/null | sort -u | grep -q CLIENT_ARRAYS; then + echo "::error::integration-clientarrays-split is red but no private log carries ${marker}...+CLIENT_ARRAYS\"} - the lane failed for some OTHER reason, which is a defect rather than the expected-red control" + grep -rhoE '(Fatal|Admitted)\{[A-Za-z]*, "[^"]*"\}' "${logs}"/*ClientVertexArrayScenario*.log 2>/dev/null | sort | uniq -c | sort -rn | head + exit 1 + fi + echo "integration-clientarrays-split is red by design on:" + grep -rhoE 'Fatal\{UnmigratedVerb, "(Multi)?DrawArrays\+CLIENT_ARRAYS"\}' \ + "${logs}"/*ClientVertexArrayScenario*.log | sort -u + + - name: Upload strict lane logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: integration-split-strict-logs + path: | + build-split/MobileGL/MG_IntegrationTest/*.log* + build-split/MobileGL/MG_IntegrationTest/split-logs/*.log + if-no-files-found: warn + + # MobileGL/MG_Remote/Protocol/generated/protocol_generated.h is COMMITTED, and + # flatc is deliberately absent from the default build graph (a codegen step in + # the graph is how the earlier branch ended up cross-compiling an arm64 flatc + # and trying to run it on the host). This job is what keeps the committed + # header honest: build the pinned flatc, regenerate, and fail on any diff. + # It needs no MobileGL build, so it does not depend on build-linux. + flatc-check: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Check out the FlatBuffers submodule only + # Just this one: the schema check has nothing to do with glslang, + # SPIRV-Cross or the trace fixtures. + run: git submodule update --init 3rdparty/flatbuffers + + - name: Regenerate protocol_generated.h + run: python3 scripts/gen_protocol.py --build-dir "${{ runner.temp }}/flatc-build" + + - name: Fail if the committed header is stale + run: git diff --exit-code -- MobileGL/MG_Remote/Protocol/generated/protocol_generated.h + + # P0.5 interface-purity gate A (ARCHITECTURE.md:501): the two extracted headers' include closure, + # asserted on `-H` output because `nm --undefined-only` is blind to "included but not called" - + # a header whose types are never named leaves no symbol behind, and "included at all" is exactly + # the coupling P1 and P7 have to sever. Needs a preprocessor and three header submodules, no + # CMake configure and no glslang sources, so like pipe-gates it does not depend on build-linux. + # The script's own --self-test is always on: a negative control that stopped tripping fails the + # job, because a gate that cannot go red is not a gate (ROADMAP.md:7). + include-graph-check: + name: Include-closure purity gate + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Check out the three header submodules the closure needs + # ska/flat_hash_map.hpp, xxhash.h and vulkan/vulkan.h are the only submodule headers + # Includes.h reaches; glslang and spirv_cross are vendored under include/. + run: git submodule update --init include/ska 3rdparty/xxHash 3rdparty/Vulkan-Headers + + - name: Install clang and the X11 headers vulkan.h pulls on Linux + # Includes.h defines VK_USE_PLATFORM_XLIB_KHR before , which then + # includes ; without libx11-dev every clang-mode probe dies in the + # preprocessor and the gate reports 5 problems that have nothing to do with purity. + run: sudo apt-get update && sudo apt-get install -y clang-20 libx11-dev + + - name: Include-closure assertions and negative control + # --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 needs: build-linux @@ -506,6 +1726,8 @@ jobs: outputs: matrix: ${{ steps.trace-cases.outputs.matrix }} names: ${{ steps.trace-cases.outputs.names }} + verify-matrix: ${{ steps.trace-cases.outputs.verify-matrix }} + split-matrix: ${{ steps.trace-cases.outputs.split-matrix }} steps: - name: Checkout repo uses: actions/checkout@v6 @@ -515,6 +1737,23 @@ jobs: run: | echo "matrix=$(python3 tools/trace_replay/trace_cases.py --ci --format github-test-matrix)" >> "$GITHUB_OUTPUT" echo "names=$(python3 tools/trace_replay/trace_cases.py --ci --format names)" >> "$GITHUB_OUTPUT" + # The subset the verify build retraces ("verify": true in trace_cases.json). It is a + # SUBSET of the matrix above, so retrace-verify needs no fixtures of its own. + echo "verify-matrix=$(python3 tools/trace_replay/trace_cases.py --ci --format github-verify-matrix)" >> "$GITHUB_OUTPUT" + # P5's split subset ("split": true, DirectGLES only). Also a SUBSET of the matrix above, + # so retrace-split needs no fixtures of its own either. It is one case today - OpenRA, + # which is what the phase gate names - and trace_cases.py refuses a `split` case that is + # not in CI or does not run DirectGLES, so the subset cannot silently become empty. + SPLIT_MATRIX=$(python3 tools/trace_replay/trace_cases.py --ci --format github-split-matrix) + # AND IT MUST NOT BE EMPTY. An empty `include` is not an error to GitHub - it skips the + # whole retrace-split job with no red anywhere - so the one way this subset can vanish + # silently is guarded here. trace_cases.py now also rejects an unknown manifest key, which + # was the hole: `"splitt": true` loaded clean and emptied the subset (review N-4). + if [ "$(printf '%s' "${SPLIT_MATRIX}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["include"]))')" -lt 1 ]; then + echo "::error::the split retrace subset is EMPTY. No case in trace_cases.json carries \"split\": true, so retrace-split would be skipped with no red. Exit gate E2 names OpenRA." + exit 1 + fi + echo "split-matrix=${SPLIT_MATRIX}" >> "$GITHUB_OUTPUT" trace-fixtures: name: trace fixture (${{ matrix.case }}) @@ -728,10 +1967,518 @@ jobs: archive: false if-no-files-found: error + # The trace half of the third CI mode. Same replay, same goldens, but the library underneath is + # the verify build and MOBILEGL_PIPE_VERIFY=1 is in the environment, so every backend read of + # frontend state is checked against a snapshot taken at the verb boundary. Eight cases rather + # than the full lane's 40 (tools/trace_replay/trace_cases.json, "verify": true): the comparator + # is budgeted at 5-10x, and the full sweep is a phase-exit / workflow_dispatch run. + retrace-verify: + name: retrace verify (${{ matrix.backend }}, ${{ matrix.case }}) + runs-on: ubuntu-latest + timeout-minutes: 240 + needs: + - build-linux-verify + - build-retrace + - trace-cases + - trace-fixtures + if: ${{ always() && needs.build-linux-verify.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: ${{ fromJSON(needs.trace-cases.outputs.verify-matrix) }} + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 16 + + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Download trace fixture + uses: actions/download-artifact@v8 + with: + name: trace-fixture-${{ matrix.case }} + path: trace-fixture-download + + - name: Install trace fixture + run: | + mkdir -p tools/trace_replay/fixtures + find trace-fixture-download -type f -exec cp {} tools/trace_replay/fixtures/ \; + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers + test -e /usr/lib/x86_64-linux-gnu/libEGL.so + test -e /usr/lib/x86_64-linux-gnu/libGLESv2.so + + - name: Download Linux verify runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-verify + path: . + + - name: Download trace replay + uses: actions/download-artifact@v8 + with: + name: mobilegl-trace-replay + path: . + + - name: Unpack the VERIFY runtime as the library under test + # build-retrace's CTestTestfile.cmake has the absolute path + # /build-linux/libMobileGL.so frozen into every case, so the swap happens here + # rather than through a variable: the verify .so is put where that path points. The nm + # check is what makes the swap falsifiable - a run against the ordinary library would + # carry no comparator, ignore MOBILEGL_PIPE_VERIFY entirely, and match its golden. + # + # `nm`, not `nm -D`, for the reason spelled out in build-linux-verify: everything MGPipe is + # hidden-visibility in a Release build and the dynamic table has none of it. + run: | + tar -xzf mobilegl-linux-runtime-verify.tgz + tar -xzf mobilegl-trace-replay.tgz + test -f build-verify/libMobileGL.so + test -f build-retrace/tools/trace_replay/mobilegl_trace_replay + mkdir -p build-linux + cp build-verify/libMobileGL.so build-linux/libMobileGL.so + if ! nm --defined-only build-linux/libMobileGL.so | grep -q MGPipeVerifyInputs; then + echo "::error::the library unpacked at build-linux/libMobileGL.so defines no MGPipeVerifyInputs, so this retrace would replay against a comparator-free build and pass on its golden having verified nothing" + exit 1 + fi + echo "the library at build-linux/libMobileGL.so is the verify build" + + - name: Retrace and validate under MOBILEGL_PIPE_VERIFY + working-directory: build-retrace/tools/trace_replay + # run_trace_case.cmake turns MOBILEGL_PIPE_VERIFY into three assertions of its own (the + # arming line, no Fatal{PipeVerifyDiffer, no Fatal{UnmigratedPipeInput), so a case that + # somehow ran the wrong library reds here instead of passing on its golden. + # --timeout 10800: the 1800s cases run 5-10x slower with both comparator arms live, which + # is well past ctest's 1500s default. + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + export MOBILEGL_PIPE_VERIFY=1 + if [ '${{ matrix.backend }}' = 'DirectVulkan' ]; then + export MOBILEGL_MAGMA_R11G11B10F_FALLBACK=1 + fi + if [ '${{ matrix.backend }}' = 'DirectVulkan' ] \ + && [ '${{ matrix.case }}' = 'improved-transparency-minecraft-26.3' ]; then + export MOBILEGL_MAGMA_DISABLE_BLENDED_DEPTH_WRITE=1 + fi + ctest -V --no-tests=error --timeout 10800 \ + -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$' + + # The retrace lane's own always-on negative control, on one case so it costs one short trace: + # with a snapshot field corrupted, the SAME replay must fail. Without it, "40 traces, zero + # divergences" would be a statement about a comparator nobody watched. + # + # The rerun replays into the SAME case directory, so the verified run's images are put aside + # first and restored before the verdict: "Upload actual image" below runs `if: always()` and + # would otherwise ship the deliberately corrupted run's output under the name of the good one. + # The restore happens whichever way the control goes, which is why the ctest exit status is + # captured rather than tested inline. + - name: Negative control - a corrupted snapshot field must red this retrace + if: ${{ matrix.case == 'OpenRA' && matrix.backend == 'DirectGLES' }} + working-directory: build-retrace/tools/trace_replay + run: | + export MOBILEGL_PIPE_VERIFY=1 + export MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters + GOOD_OUTPUT="${RUNNER_TEMP}/openra-verified-output" + rm -rf "${GOOD_OUTPUT}" + if [ -d OpenRA ]; then + cp -a OpenRA "${GOOD_OUTPUT}" + fi + set +e + ctest -V --no-tests=error --timeout 10800 \ + -R '^MobileGLTraceReplay\.OpenRA\.DirectGLES$' + control_rc=$? + set -e + if [ -d "${GOOD_OUTPUT}" ]; then + rm -rf OpenRA + mv "${GOOD_OUTPUT}" OpenRA + echo "restored the verified run's OpenRA output over the corrupted rerun's" + fi + if [ "${control_rc}" -eq 0 ]; then + echo "::error::MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters left the OpenRA retrace GREEN, so the comparator is not comparing and the whole verify retrace lane proves nothing." + exit 1 + fi + echo "the corrupted field turned the retrace red, as it must (ctest exit ${control_rc})" + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: retrace-verify-core-dumps-${{ matrix.backend }}-${{ matrix.case }} + path: /tmp/core.* + if-no-files-found: ignore + + - name: Upload actual image + if: always() + uses: actions/upload-artifact@v7 + with: + name: retrace-verify-result-${{ matrix.backend }}-${{ matrix.case }} + path: | + build-retrace/tools/trace_replay/${{ matrix.case }}/actual-images/** + build-retrace/tools/trace_replay/${{ matrix.case }}/${{ matrix.backend }}/output/** + if-no-files-found: warn + + # P5's retrace arm: retrace-verify with MOBILEGL_PIPE_VERIFY=1 swapped for + # MOBILEGL_TRANSPORT=inproc and the comparator's symbol swapped for an MG_Remote one. Exit gate + # E2 is one row - OpenRA at SSIM >= 0.99 - and trace_cases.json's `split: true` is where that + # subset lives. + # + # IT RUNS THE UNCHANGED CTEST NAMES with the transport exported in the JOB environment, rather + # than the SPLIT-suffixed variant entries. build-retrace configures without + # -DMOBILEGL_BUILD_DISAGGREGATED, so the variant entries are deliberately not registered there + # (a name that says SPLIT in a build that cannot be one is worse than no name), and the + # unchanged names are what retrace-verify already proves this shape works with. The variant + # entries exist for a build that DOES configure the option - a local build-split with trace + # replay on - where `ctest -L retrace-split` is self-describing and needs no environment ritual. + # + # WHAT MAKES IT FALSIFIABLE is not the SSIM. A monolith run of OpenRA also scores 1.000: measured + # on this branch, a pull library under MOBILEGL_TRANSPORT=inproc produced ssim=1.0 and was caught + # only by run_trace_case.cmake's transport-resolution assertion. So there are two guards, and the + # SSIM is neither of them: the nm check below (this library carries MG_Remote) and the + # transport-resolution line in the library's own log (this PROCESS resolved inproc). + retrace-split: + name: retrace split (${{ matrix.backend }}, ${{ matrix.case }}) + runs-on: ubuntu-latest + timeout-minutes: 240 + needs: + - build-linux + - build-linux-split + - build-retrace + - trace-cases + - trace-fixtures + # build-linux is needed for the negative control ONLY: its pull library is what the control + # swaps in to prove the transport-resolution assertion is load-bearing. + if: ${{ always() && needs.build-linux.result == 'success' && needs.build-linux-split.result == 'success' && needs.build-retrace.result == 'success' && needs.trace-cases.result == 'success' }} + strategy: + fail-fast: false + max-parallel: 4 + matrix: ${{ fromJSON(needs.trace-cases.outputs.split-matrix) }} + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 16 + + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Download trace fixture + uses: actions/download-artifact@v8 + with: + name: trace-fixture-${{ matrix.case }} + path: trace-fixture-download + + - name: Install trace fixture + run: | + mkdir -p tools/trace_replay/fixtures + find trace-fixture-download -type f -exec cp {} tools/trace_replay/fixtures/ \; + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Install runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y libvulkan1 libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers + test -e /usr/lib/x86_64-linux-gnu/libEGL.so + test -e /usr/lib/x86_64-linux-gnu/libGLESv2.so + + - name: Download Linux split runtime + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime-split + path: . + + - name: Download trace replay + uses: actions/download-artifact@v8 + with: + name: mobilegl-trace-replay + path: . + + # The PULL runtime, for the negative control at the end of this job and for nothing else. + - name: Download Linux pull runtime (negative control) + uses: actions/download-artifact@v8 + with: + name: mobilegl-linux-runtime + path: pull-runtime + + - name: Unpack the pull runtime (negative control) + run: | + tar -xzf pull-runtime/mobilegl-linux-runtime.tgz -C pull-runtime + test -f pull-runtime/build-linux/libMobileGL.so + + - name: Unpack the SPLIT runtime as the library under test + # build-retrace's CTestTestfile.cmake has the absolute path + # /build-linux/libMobileGL.so frozen into every case, so the swap happens here + # rather than through a variable, exactly as in retrace-verify. `nm`, not `nm -D`. + run: | + tar -xzf mobilegl-linux-runtime-split.tgz + tar -xzf mobilegl-trace-replay.tgz + test -f build-split/libMobileGL.so + test -f build-retrace/tools/trace_replay/mobilegl_trace_replay + mkdir -p build-linux + cp build-split/libMobileGL.so build-linux/libMobileGL.so + if ! nm --defined-only build-linux/libMobileGL.so | grep -q -i MG_Remote; then + echo "::error::the library unpacked at build-linux/libMobileGL.so defines no MG_Remote symbol, so this retrace would replay against a monolith build, ignore MOBILEGL_TRANSPORT entirely and match its golden having split nothing" + exit 1 + fi + echo "the library at build-linux/libMobileGL.so is the split build" + + - name: Retrace and validate under MOBILEGL_TRANSPORT=inproc + working-directory: build-retrace/tools/trace_replay + # run_trace_case.cmake turns MOBILEGL_TRANSPORT into assertions of its own - the library + # must have RESOLVED the transport in this process, and its log must carry no Fatal{ - so a + # case that somehow ran the wrong library reds here instead of passing on its golden. That + # log is also the only valid refusal census: the console sink is compiled out of this + # configuration, so a `ctest -V` transcript reports a false zero for Fatal{ lines. + run: | + ulimit -c unlimited + sudo sysctl -w kernel.core_pattern='/tmp/core.%e.%p' + export MOBILEGL_TRANSPORT=inproc + ctest -V --no-tests=error --timeout 10800 \ + -R '^MobileGLTraceReplay\.${{ matrix.case }}\.${{ matrix.backend }}$' + + # THE RETRACE LANE'S OWN ALWAYS-ON NEGATIVE CONTROL, and its absence was review finding M-6: + # this job was a clone of retrace-verify with the one step removed that makes the lane mean + # anything. retrace-verify carries "a corrupted snapshot field must red this retrace" so that + # "79 traces, zero divergences" is not a statement about a comparator nobody watched; the + # same sentence applies here word for word. + # + # The control swaps the PULL library into the frozen path and requires the SAME replay to + # fail. It is the sharpest one available, because OpenRA scores ssim 1.000000 either way - + # measured - so this step fails if and only if run_trace_case.cmake's transport-resolution + # assertion has stopped working, which is the single thing standing between this job and a + # green that ran monolith end to end. + # + # NOT the control BRIEF 7 E2 names ("patch the Clear emitter to drop one emission; SSIM must + # fall below threshold"). That one needs an emitter, i.e. package c1, and it is carried as an + # explicit debt in t1-v1.md rather than silently substituted - which is what the first + # version of this package did. + # + # The rerun replays into the same case directory, so the good run's images are put aside and + # restored whichever way the control goes; "Upload actual image" below runs `if: always()` + # and would otherwise ship the deliberately-wrong run's output under the good run's name. + # THE BODY OF THIS STEP IS scripts/ci/retrace_pull_library_control.sh, for the reason the + # split lane's control gives: a `run:` block cannot be executed off a runner, so these lines + # could not be tested until they ran in CI. scripts/ci/control_smoke_test.sh drives that file. + # + # Two holes ID-46 finding 8(b) found in this block, both CONFIRMED against the REAL ctest in a + # REAL build tree, both closed in the script: it had NO selection guard at all - unlike the + # split lane's run_control - so a case/backend regex matching nothing exited 8 through + # `--no-tests=error` and was read as "the pull library turned it red"; and only "non-zero + # ctest" was checked after the nm identity check, so a loader failure, a missing fixture or a + # timeout passed it. The red must now carry run_trace_case.cmake's own sentence. + - name: Negative control - the PULL library must red this split retrace + working-directory: build-retrace/tools/trace_replay + env: + CONTROL_TMPDIR: ${{ runner.temp }} + PULL_LIBRARY: ${{ github.workspace }}/pull-runtime/build-linux/libMobileGL.so + FROZEN_LIBRARY: ${{ github.workspace }}/build-linux/libMobileGL.so + run: >- + bash "${GITHUB_WORKSPACE}/scripts/ci/retrace_pull_library_control.sh" + '${{ matrix.case }}' '${{ matrix.backend }}' + + # EXIT GATE E2's OTHER HALF, and the one BRIEF-P5 §7 E2 actually names: "drop an emission + # and the SSIM must fall below the threshold". The control above proves the lane runs a + # SPLIT library; it cannot prove the PICTURE came through the wire, because OpenRA scores + # ssim 1.000000 against a monolith library too and the transport assertion is what reds it. + # + # It drops the DRAWS and not the clears, and that is a measurement rather than a + # preference: with MOBILEGL_IPC_E2_DROP_CLEAR=1 armed, read, and all 29 of OpenRA's Clear + # records dropped, the retrace still scored ssim=1.000000 / mismatchPixels=0 - OpenRA + # overdraws every pixel it clears before the snapshot (joint-v1.md §3 found the green; + # scripts/ci/retrace_drop_draw_control.sh's header has the trace census that explains it). + # With the DrawVbo records dropped instead: 758 dropped, ssim=0.000036, + # mismatchPixels=295296. + # + # THE BODY IS A SCRIPT for the reason the two steps above give: a `run:` block cannot be + # executed off a runner, and scripts/ci/control_smoke_test.sh drives this file against a + # stubbed ctest in seven modes - including the two that matter most, a red whose SSIM never + # fell and a knob that armed and dropped nothing. + - name: Negative control - dropping the draws on the wire must red this split retrace + working-directory: build-retrace/tools/trace_replay + env: + CONTROL_TMPDIR: ${{ runner.temp }} + LIBRARY_LOG: ${{ matrix.case }}/${{ matrix.backend }}/output/mobilegl.log + FROZEN_LIBRARY: ${{ github.workspace }}/build-linux/libMobileGL.so + run: >- + bash "${GITHUB_WORKSPACE}/scripts/ci/retrace_drop_draw_control.sh" + '${{ matrix.case }}' '${{ matrix.backend }}' + + # The refusal census, recorded rather than gated. run_trace_case.cmake already REDS the case + # on any Fatal{, so reaching here means the count is zero - but the number and the distinct + # slot names are what MEASUREMENTS wants from every split run, and reading them out of the + # library's own log is the only way to get them (ctest -V's transcript is a false zero). + - name: Refusal census from the library log + if: always() + working-directory: build-retrace/tools/trace_replay + run: | + log="${{ matrix.case }}/${{ matrix.backend }}/output/mobilegl.log" + if [ ! -f "${log}" ]; then + echo "no ${log} - the replay wrote no library log" + exit 0 + fi + echo "Fatal{ lines: $(grep -c 'Fatal{' "${log}" || true)" + grep -o 'Fatal{[A-Za-z]*, "[^"]*"' "${log}" | sort | uniq -c | sort -rn | head -20 || true + grep -m1 "MOBILEGL_TRANSPORT=" "${log}" || echo "no transport line in ${log}" + + - name: Upload core dumps + if: failure() + uses: actions/upload-artifact@v7 + with: + name: retrace-split-core-dumps-${{ matrix.backend }}-${{ matrix.case }} + path: /tmp/core.* + if-no-files-found: ignore + + - name: Upload actual image + if: always() + uses: actions/upload-artifact@v7 + with: + name: retrace-split-result-${{ matrix.backend }}-${{ matrix.case }} + path: | + build-retrace/tools/trace_replay/${{ matrix.case }}/actual-images/** + build-retrace/tools/trace_replay/${{ matrix.case }}/${{ matrix.backend }}/output/** + if-no-files-found: warn + + # G1's own job: the pull build must be the tree before P1, symbol for symbol and byte for byte. + # workflow_dispatch only - it builds the library twice from scratch, and its answer is about a + # BASELINE rather than about this push, so a per-push run would be measuring the wrong pair. + monolith-symbol-report: + name: monolith symbol report + runs-on: ubuntu-latest + timeout-minutes: 180 + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + CCACHE_BASEDIR: ${{ github.workspace }} + CCACHE_COMPRESS: "true" + CCACHE_DIR: ${{ github.workspace }}/.ccache + CCACHE_MAXSIZE: 4G + CCACHE_NOHASHDIR: "true" + + steps: + - name: Set Swap Space + uses: pierotofy/set-swap-space@v1.0 + with: + swap-size-gb: 32 + + - name: Checkout repo + uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 + + - name: Get CMake + uses: lukka/get-cmake@v4.3.3 + + - name: Restore ccache + uses: actions/cache/restore@v5 + with: + path: .ccache + key: ${{ runner.os }}-test-${{ github.job }}-ccache-v1 + restore-keys: | + ${{ runner.os }}-test-${{ github.job }}-ccache- + + - name: Prepare Vulkan SDK + uses: humbletim/setup-vulkan-sdk@v1.2.1 + with: + vulkan-query-version: 1.4.304.1 + vulkan-components: Vulkan-Headers, Vulkan-Loader + vulkan-use-cache: true + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y ccache clang-20 clang++-20 lld-20 libc++-20-dev libc++abi-20-dev libvulkan-dev libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dri mesa-vulkan-drivers ninja-build binutils + + # Both sides with IDENTICAL flags, LTO off, the same compiler and the same standard library: + # symbol_report.py's guard rails (scripts/symbol_report.py) say a mismatched pair "adds" + # thousands of symbols and the comparison then means nothing. The library alone - no tests, + # no benchmark, no integration test, no trace replay - because those targets do not ship. + - name: Build the baseline library (${{ inputs.baseline_sha }}) + run: | + git worktree add ../baseline "${{ inputs.baseline_sha }}" + cd ../baseline + git submodule update --init --recursive + (cd 3rdparty/glslang && python update_glslang_sources.py) + cmake -S . -B build-sym-base -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=OFF -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=OFF -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_BUILD_DISAGGREGATED=OFF \ + -DMOBILEGL_PIPE_PUSH=OFF -DMOBILEGL_PIPE_VERIFY=OFF \ + -DMOBILEGL_ENABLE_LTO=OFF \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build build-sym-base --parallel "$(nproc)" + cp build-sym-base/libMobileGL.so "${GITHUB_WORKSPACE}/libMobileGL-baseline.so" + + - name: Build the head library + run: | + (cd 3rdparty/glslang && python update_glslang_sources.py) + cmake -S . -B build-sym-head -G Ninja \ + -DCMAKE_C_COMPILER=clang-20 -DCMAKE_CXX_COMPILER=clang++-20 \ + -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ + -DCMAKE_BUILD_TYPE=Release \ + -DMOBILEGL_LOG_ACTIVE_LEVEL=MOBILEGL_LOG_LEVEL_INFO \ + -DMOBILEGL_BUILD_TEST=OFF -DMOBILEGL_BUILD_BENCHMARK=OFF \ + -DMOBILEGL_BUILD_INTEGRATION_TEST=OFF -DMOBILEGL_BUILD_TRACE_REPLAY=OFF \ + -DMOBILEGL_BUILD_DISAGGREGATED=OFF \ + -DMOBILEGL_PIPE_PUSH=OFF -DMOBILEGL_PIPE_VERIFY=OFF \ + -DMOBILEGL_ENABLE_LTO=OFF \ + -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build build-sym-head --parallel "$(nproc)" + + # The monolith must not have grown a remote half. ARCHITECTURE.md:506: MG_Remote lives behind + # MOBILEGL_BUILD_DISAGGREGATED and nothing of it may reach a shipped pull build. + - name: No MG_Remote in the pull build + run: | + if nm --defined-only build-sym-head/libMobileGL.so | grep -q MG_Remote; then + echo "::error::the pull build defines MG_Remote symbols; the disaggregated half leaked into the monolith" + nm --defined-only build-sym-head/libMobileGL.so | grep MG_Remote | head -20 + exit 1 + fi + echo "no MG_Remote symbols in the pull build" + + - name: Symbol report (G1) + run: | + python3 scripts/symbol_report.py \ + --before libMobileGL-baseline.so \ + --after build-sym-head/libMobileGL.so \ + --threshold 0 \ + --fail-on-symbol-set-change \ + --fail-on-added-bytes 0 \ + --markdown symbol-report.md \ + --json symbol-report.json + + - name: Upload the symbol report + if: always() + uses: actions/upload-artifact@v7 + with: + name: monolith-symbol-report + path: | + symbol-report.md + symbol-report.json + if-no-files-found: error + remove-artifact-clutter: name: remove artifact clutter runs-on: ubuntu-latest - needs: retrace-summary + # (d) retrace-verify too: this job deletes the trace-fixture-* artifacts, and the verify + # retraces download the same ones. + needs: + - retrace-summary + - retrace-verify if: always() permissions: actions: write @@ -740,14 +2487,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # Both retrace lanes, not just the pull one: `retrace verify (backend, case)` downloads + # the same trace-fixture- artifact, and a failed verify retrace is exactly when + # someone needs that fixture to reproduce locally. The two prefixes are stripped in + # order, longest first, because "retrace (" is not a prefix of "retrace verify (". declare -A failed_cases=() while IFS= read -r job_name; do - case_name="${job_name#retrace (*, }" + case_name="${job_name#retrace verify (*, }" + case_name="${case_name#retrace (*, }" case_name="${case_name%)}" failed_cases["${case_name}"]=1 done < <( gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?per_page=100" \ - --jq '.jobs[] | select(.name | startswith("retrace (")) | select(.conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out" or .conclusion == "action_required") | .name' + --jq '.jobs[] | select((.name | startswith("retrace (")) or (.name | startswith("retrace verify ("))) | select(.conclusion == "failure" or .conclusion == "cancelled" or .conclusion == "timed_out" or .conclusion == "action_required") | .name' ) if ((${#failed_cases[@]})); then @@ -778,3 +2530,207 @@ jobs: ) echo "Deleted ${deleted} intermediate Linux artifact(s); retained ${retained} failed-retrace fixture(s)." + + pipe-gates: + name: MGPipe generators and hygiene gates + runs-on: ubuntu-latest + # Deliberately independent of build-linux: these are source-level gates, they take + # seconds, and a broken build must not hide a drifted interface. + env: + # THE CURRENT PHASE's base ref, for the two G5 region gates below. It is 37da3c3a - P4a's + # base ref, INTEGRATOR-DECISIONS ID-1 - and NOT the workflow's baseline_sha input: that input + # is the SYMBOL baseline (087685d1, P1's G1 reading) and it is empty on a push, whereas these + # gates ask "did the do-not-touch list move since the phase started". + # + # IT MOVED FROM P3a's 44c2b5cf TO P4a's 37da3c3a WITH THE PHASE, and that is a deliberate + # narrowing rather than a loss: P3a's eleven functions were compared against 44c2b5cf at P3a's + # own exit and were byte-identical there, so 37da3c3a carries the same bodies (measured: the + # eleven shas at 37da3c3a are the eleven shas at 44c2b5cf, and FlushPendingRangesFrom's is + # still the sha pinned in the script at 3e298c9a). What the two gates now both answer is "did + # anything on the list move during P4a", which is the question this phase can act on. + BASELINE: "37da3c3a" + + steps: + - name: Checkout repo + uses: actions/checkout@v6 + with: + # The G5 gate reads Managers.cpp at BASELINE with `git show`, which a depth-1 checkout + # does not have. Nothing else in this job needs history. + fetch-depth: 0 + + # The seven generators all read MG_Pipe/*.def, so regenerating and diffing is what + # keeps the two interface tables, the wire records, the verify comparators, the + # PipeInputs field ids, the read-inventory coverage and the render-state member list + # from drifting apart from the catalogue. The generated files are committed + # deliberately: the build must not depend on python. + - name: Regenerate the MGPipe interface (G1-G7) + run: | + python3 scripts/gen_pipe.py + git diff --exit-code -- MobileGL/MG_Pipe/generated + + # The generators' own negative controls: canned inputs that MUST trip each structural check + # (a field list that does not cover its struct's members, a verb set that is not the function + # table's). Regenerating and diffing above cannot see a check that silently stopped + # checking - a broken gate and a clean tree produce the same green. + - name: The MGPipe generators' checks can still fail + run: python3 scripts/gen_pipe.py --self-test + + # The same question for the symbol tool the P1 gate is written in terms of. + - name: The symbol report's buckets and gates can still fail + run: python3 scripts/symbol_report.py --self-test + + # Per-draw fprintf/printf instrumentation has repeatedly been committed by accident, + # once inside a mutex critical section. Nothing under these two trees prints to a + # stdio stream today - MGLOG_D compiles out in INFO builds and is the only channel + # they are allowed to use - so this gate starts with no exceptions, and any addition + # to it needs a reason in the pull request rather than a quiet whitelist entry. The + # alternation names every stdio spelling, not just the two that were committed: + # fprintf to either stream, printf, puts, and the iostream pair. + - name: No stdio instrumentation in MG_Backend or MG_State + run: | + if grep -rnE 'fprintf[[:space:]]*\((stderr|stdout)|(^|[^[:alnum:]_>.])printf[[:space:]]*\(|(^|[^[:alnum:]_>.:])puts[[:space:]]*\(|std::(cout|cerr)' \ + MobileGL/MG_Backend MobileGL/MG_State; then + echo "::error::stdio instrumentation found; use MGLOG_D (compiled out in INFO builds)" + exit 1 + fi + echo "no fprintf(stderr/stdout / printf( / puts( / std::cout|cerr under MobileGL/MG_Backend or MobileGL/MG_State" + + # A GATE as of P2, which is when MG_Pipe/DirtySurface.def exists to diff the scan against + # (ROADMAP.md:18 puts the first mapping round in P2). --check fails BOTH directions: a + # mutator the scanner finds with no row in the def, and a row naming a mutator the scan no + # longer finds - so a deleted mutator cannot leave a stale row behind claiming coverage. + # + # --self-test is the half that keeps --check honest, and it is not optional. A completeness + # check that silently stopped checking produces exactly the same green as a complete + # mapping; the self-test feeds it two canned negative controls (a mutator withheld from the + # def, a row naming a function that does not exist) and fails if either fails to trip. Same + # shape as gen_pipe.py --self-test and check_include_closure.py above. + # + # What this gate does NOT cover is written into DirtySurface.def's header rather than left + # implicit: the scanner attributes a mutation inside a lambda to the enclosing function, + # reads a mutation published through a helper as deferred, and scans only MG_Impl/GLImpl - + # so the four MGP_NOTE_MUTATION sites in MG_State are outside it entirely. This is a + # completeness gate over what the scanner can see; the semantic proof is the verify lane. + - name: MGPipe dirty-surface mapping is complete (G9) + run: | + python3 scripts/gen_pipe_dirty_surface.py --check + python3 scripts/gen_pipe_dirty_surface.py --self-test + + # A GATE as of P5 (R-7.1, CONTRACT-P5.md table 2). The eighth generator, and the only one + # whose output the step above cannot cover: `gen_pipe.py` does not write + # PipeFieldOwnership.inc, so regenerating the seven and diffing MG_Pipe/generated leaves a + # drifted ownership table green. The build-level static_assert only catches an + # UNCLASSIFIED row; a hand edit that CHANGES a class compiles clean, and --check is the + # only thing in the tree that catches it. + # + # NO BRANCH GUARD, deliberately, unlike the two G5 steps below: this asks "does the + # committed table still follow from the .def files", which is a question every branch can + # answer and every branch wants answered. The table outlives feat/disaggregated. + # + # --self-test is not optional, for the reason the two gates above give and for one this + # generator learned the hard way: its first version counted eleven trips when one control + # was a silent duplicate of another, because the harness asked "did something exit" rather + # than "did THIS exit". Every control now asserts its own message, and a harness control + # asserts that the harness still rejects someone else's exit. + - name: MGPipe PipeInputs field-ownership table is complete (R-7.1, table 2) + run: | + python3 scripts/gen_pipe_field_ownership.py --check + python3 scripts/gen_pipe_field_ownership.py --self-test + + # R-16 APPLIED TO THE NEGATIVE CONTROLS THEMSELVES. The split lane's E1/E3(a) controls and the + # retrace lane's pull-library control are gates, and until ID-46 finding 8 neither could be + # made red by anyone: their bodies were `run:` blocks, which execute only on a runner. Both + # bodies now live in scripts/ci/, and this step runs them against a stubbed ctest that + # reproduces the finding - a NON-EMPTY selection failing with UNRELATED_CONTROL_FAILURE, and a + # case/backend regex matching no tests - and requires each control to report FAILED. The same + # stub, failing with the diagnostics the scenarios really emit, must make them report PASSED. + # + # NO BRANCH GUARD: this asks "do the negative controls still reject a red that is not theirs", + # which is a question every branch can answer and none of which depends on the TEMPORARY + # feat/disaggregated trigger at the top of this file. It costs a couple of seconds and needs + # no build. + - name: The split and retrace negative controls still reject a red that is not theirs (R-16) + run: bash scripts/ci/control_smoke_test.sh + + # A GATE as of P3a (G5). "pool 与延迟释放原样搬" (ROADMAP.md:19) is meant literally: the + # buffer pool, the deferred-release drain and the three persistently mapped rings move + # VERBATIM, and ARCHITECTURE.md:515 says why - their retire happens only inside Present, so + # a batching or ordering change there starves them, and nothing else in this workflow can + # see it. P3a rewrites the rest of Managers.cpp by design, so a file diff says nothing; the + # script extracts the ELEVEN named bodies and compares their hashes on their own. + # + # Eleven and not ten (ID-15): Managers.cpp carries the three-tier flush drain TWICE, once + # per preprocessor arm, and a push build compiles only FlushPendingRangesFrom while the + # untouched FlushPendingRangesNow lives in the `#else`. Hashing the pull name alone would + # protect text no shipping build compiles, so both are hashed - the pull ladder against + # BASELINE, the push ladder against a sha pinned in the script at 3dadd4c1 (ID-41), because + # that one was born in P3a and is compared against the reviewed body rather than against the + # base ref. + # + # Scoped to the disaggregation branch and to a manual dispatch, deliberately: the question + # is "did these eleven move since P3a started", and BASELINE is P3a's base ref. On dev, + # where unrelated buffer fixes land on their own schedule, the same comparison would be + # asking a question nobody posed - it belongs with the TEMPORARY trigger lines at the top + # of this file and retires with them. + # + # --self-test is the half that keeps it honest, and it is not optional: a comparison that + # silently stopped comparing produces exactly the same green as eleven untouched bodies. It + # runs eight canned controls - eleven bodies extracted, an untouched copy compared equal, an + # edit OUTSIDE them ignored, each of the three perturbation targets (ClearBufferPool and BOTH + # flush ladders) reported BY NAME, plus the pin-precedence control and a one-token edit to the + # pinned ladder compared against the pin - and fails if any of them does not answer. + # Same shape as gen_pipe.py --self-test above. + - name: The buffer pool, the deferred-release drain and the rings did not move (G5) + if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }} + run: bash scripts/p3a_untouched_regions.sh "${BASELINE}" HEAD + + - name: The untouched-region gate can still fail (G5) + if: ${{ github.ref == 'refs/heads/feat/disaggregated' || github.event_name == 'workflow_dispatch' }} + run: bash scripts/p3a_untouched_regions.sh --self-test + + # A GATE AS OF P4a (G5), and a SECOND script rather than an edit to the one above. P4a extends + # the same claim to the rest of ARCHITECTURE.md:318's do-not-touch list - the unpack PBO ring's + # staging repack and its two helpers, the attachment permutation, the D24S8 sampling-emulation + # core and the format-caveat handler - which is SEVENTEEN regions across THREE files + # (BRIEF-P4A.md D-N: P3a's eleven, which P4a must not touch either, plus P4a's six). The + # parent's SOURCE_PATH is a single file, so the extension needed a per-region source path and + # a region KIND (DepthStencilSamplingReadImpl is a namespace, not a function); everything else + # about the extraction is its parent's, verbatim. + # + # Both scripts run. The parent keeps answering its own question against its own eleven, so a + # regression in either half names itself, and neither gate can be silenced by editing the + # other's list. + # + # Same feat/disaggregated-or-dispatch guard as the P3a step, for the same reason: the question + # is "did these move since the phase started", and on dev - where unrelated buffer and texture + # fixes land on their own schedule - it would be a question nobody posed. It belongs with the + # TEMPORARY trigger lines at the top of this file and retires with them. + # + # --self-test is the half that keeps it honest and is not optional: a comparison that silently + # stopped comparing produces exactly the same green as seventeen untouched regions. It runs + # three positive controls (seventeen regions extracted, an untouched copy compared equal, an + # edit OUTSIDE them invisible in all three files) and NINE negative ones - ClearBufferPool, + # FlushPendingRangesNow, RecomputeBackendColorSlots and StageBlocksIntoUnpackRing, each + # perturbed at its HEAD and at its TAIL and each required to be named BY NAME, plus a + # one-token edit to the pinned FlushPendingRangesFrom body compared against the pin - 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. + - name: Documentation citation lint + run: | + shopt -s nullglob + documents=(docs/Disaggregated/*.md) + if [ ${#documents[@]} -eq 0 ]; then + echo "no disaggregation documents to check" + exit 0 + fi + python3 scripts/check_doc_citations.py "${documents[@]}" || true diff --git a/.gitmodules b/.gitmodules index 3ae968ea5..20ea83612 100644 --- a/.gitmodules +++ b/.gitmodules @@ -34,3 +34,6 @@ [submodule "include/ska"] path = include/ska url = https://github.com/MobileGL-Dev/flat_hash_map.git +[submodule "3rdparty/flatbuffers"] + path = 3rdparty/flatbuffers + url = https://github.com/google/flatbuffers.git diff --git a/3rdparty/flatbuffers b/3rdparty/flatbuffers new file mode 160000 index 000000000..7e163021e --- /dev/null +++ b/3rdparty/flatbuffers @@ -0,0 +1 @@ +Subproject commit 7e163021e59cca4f8e1e35a7c828b5c6b7915953 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d5c78054..b029d34e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,33 @@ option(MOBILEGL_ENABLE_TRACY "Enable tracy for profiling" option(MOBILEGL_BUILD_TRACE_REPLAY "Build desktop apitrace replay runner" OFF) option(MOBILEGL_TRACE_ANGLE_VARIANTS "Enable signed trace-APK ANGLE variant loading" OFF) option(MOBILEGL_IOS "Build MobileGL for iOS instead of macOS when APPLE is set" OFF) +# The disaggregated (two-process) shape. OFF is the shipping default and OFF +# must stay byte-comparable to a tree without MG_Remote at all: nothing under +# MobileGL/MG_Remote/ is compiled, no include path is added, and no library is +# linked, so `nm --defined-only libMobileGL.so | grep -i MG_Remote` is empty. +# That emptiness is one of the two byte-level equalities the plan's validation +# gates keep (section 10.3). +option(MOBILEGL_BUILD_DISAGGREGATED "Build the MG_Remote transport layer (two-process shape)" OFF) +# The CI / debugging shape (ARCHITECTURE.md:581): both roles in ONE process, talking over the +# same SEG_CMD ring and the same G3 codec a spawned server would use. It IMPLIES +# MOBILEGL_BUILD_DISAGGREGATED (below) and additionally admits the role-isolation shims that +# only make sense when the two roles share an address space. It is a SUPERSET, never a +# substitute: MOBILEGL_TRANSPORT=inproc is what selects the shape at run time, and this option +# only decides whether the shims are compiled in. +option(MOBILEGL_BUILD_DISAGGREGATED_INPROC "Compile the in-process (one-process, two-role) split shims; implies MOBILEGL_BUILD_DISAGGREGATED" OFF) +option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery-chain executable (Android only)" OFF) +# The PipeInputs strangler (ARCHITECTURE.md 9.2). OFF is the pull build and must stay +# byte-identical to a tree without either option: MGB_CTX is the live GLContext, no +# MGPipe/PipeInputs source is compiled, every MGP_FILL is ((void)0). +option(MOBILEGL_PIPE_PUSH "Backends read frontend state through the MGPipe PipeInputs block instead of MG_State::pGLContext (ARCHITECTURE.md 9.2 phase A)" OFF) +option(MOBILEGL_PIPE_VERIFY "Compile SnapshotFromGLContext() and the G4 per-verb shadow comparator; implies MOBILEGL_PIPE_PUSH; never shipped" OFF) +# Track H's old-versus-new arm (ARCHITECTURE.md 9.6). With a MOBILEGL_PIPE_PUSH bit clear +# the backend would still run the RE-KEYED memo code, so the bitmask alone stops being a +# valid A/B the moment a handle wave lands: this option compiles the pre-handle arm - the +# registries, OwnerEquals, the TwinLookupMemos, g_fbSlotCache, ComputePipelineStateHash, +# the address-keyed VaoDrawMemo - beside it, behind the same PipeInputs interface. ON for +# the whole migration window; it retires with the pull path itself at P13. +option(MOBILEGL_PIPE_LEGACY_MEMOS "Compile the pre-handle memo arm beside the {slot, gen} arm so Track H has a real A/B (ARCHITECTURE.md 9.6)" ON) set(MOBILEGL_LOG_ACTIVE_LEVEL "MOBILEGL_LOG_LEVEL_INFO" CACHE STRING "MobileGL active log level macro") set(MOBILEGL_VULKAN_LIBRARY "" CACHE FILEPATH "Vulkan loader/MoltenVK library to link for iOS builds") @@ -238,6 +265,8 @@ set(SOURCE_FILES MobileGL/MG_Util/Metrics/BufferMetrics.cpp + MobileGL/MG_Util/Metrics/PipeStats.cpp + MobileGL/MG_Util/Converters/GLToStr/GLEnumConverter.cpp MobileGL/MG_Util/Converters/EGLToStr/EGLEnumConverter.cpp MobileGL/MG_Util/Converters/MGToStr/DataTypeConverter.cpp @@ -418,6 +447,157 @@ set(SOURCE_FILES MobileGL/MG_State/GLState/RenderbufferState/RenderbufferState.cpp ) +# --------------------------------------------------------------------------- +# MG_Remote (disaggregated transport). Everything below is gated: with the +# option OFF not one file here is compiled and no include path is added. +# --------------------------------------------------------------------------- + +# MOBILEGL_BUILD_DISAGGREGATED_INPROC implies MOBILEGL_BUILD_DISAGGREGATED. A normal +# variable, not a forced cache write, for the reason the two implications below give: an +# operator's cache entry stays theirs and only this configure is shadowed. Ordered BEFORE the +# flatbuffers guard so that a missing submodule turns BOTH off together - an INPROC build +# with the transport shadowed off would compile a role-isolation shim over no roles. +if (MOBILEGL_BUILD_DISAGGREGATED_INPROC AND NOT MOBILEGL_BUILD_DISAGGREGATED) + message(STATUS "MobileGL: MOBILEGL_BUILD_DISAGGREGATED_INPROC=ON forces " + "MOBILEGL_BUILD_DISAGGREGATED ON for this configure") + set(MOBILEGL_BUILD_DISAGGREGATED ON) +endif() + +# FlatBuffers is a submodule and its runtime is header-only. Guard both ways: +# a checkout without the submodule must configure and build, just without the +# disaggregated shape, rather than fail with a missing-header error a hundred +# lines later. Note this only checks for the RUNTIME headers - flatc is never +# built here (see scripts/gen_protocol.py). +if (MOBILEGL_BUILD_DISAGGREGATED AND + NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/flatbuffers/include/flatbuffers/flatbuffers.h") + message(WARNING + "MOBILEGL_BUILD_DISAGGREGATED=ON but 3rdparty/flatbuffers/include is missing. " + "Run `git submodule update --init 3rdparty/flatbuffers`. Building without the " + "disaggregated shape for this configure; the cached ON takes effect once the " + "submodule is present.") + # A NORMAL variable, deliberately not `CACHE BOOL ... FORCE`: forcing OFF into the cache + # made the plain re-configure after `git submodule update` stay OFF with no message at + # all. Shadowing the cache entry for this configure only keeps the operator's ON where it + # was, so the next configure - with the submodule there - honours it. + set(MOBILEGL_BUILD_DISAGGREGATED OFF) + # And with it the shim option, or the `-DMOBILEGL_BUILD_DISAGGREGATED_INPROC=1` below + # would still be defined over a build with no MG_Remote in it at all. + set(MOBILEGL_BUILD_DISAGGREGATED_INPROC OFF) +endif() + +# MOBILEGL_BUILD_DISAGGREGATED implies MOBILEGL_PIPE_PUSH (P5 c0). The split path IS the +# pushed path: MG_Remote's server decodes records into the MGPipeApply* entry points, which +# live in MG_Pipe/PipeApply.cpp, which the PIPE_PUSH block below is what compiles. Without +# this, `-DMOBILEGL_BUILD_DISAGGREGATED=ON` alone configures and then fails to link the +# applier - and the shape it fails in (MG_Remote compiled, no applier) is indistinguishable +# at the CMake level from a legitimate transport-only build, which is why it is stated here +# rather than left to whoever hits the link error. Same normal-variable form as the two +# implications above. +if (MOBILEGL_BUILD_DISAGGREGATED AND NOT MOBILEGL_PIPE_PUSH) + message(STATUS "MobileGL: MOBILEGL_BUILD_DISAGGREGATED=ON forces MOBILEGL_PIPE_PUSH ON for " + "this configure: the split path decodes into the MGPipe applier, and the " + "applier is what MOBILEGL_PIPE_PUSH compiles") + set(MOBILEGL_PIPE_PUSH ON) +endif() + +# MOBILEGL_PIPE_VERIFY implies MOBILEGL_PIPE_PUSH: the comparator compares the pushed block +# against a snapshot, so there has to be a pushed block. A normal variable, not a forced +# cache write, for the same reason as the disaggregated fallback above. +if (MOBILEGL_PIPE_VERIFY AND NOT MOBILEGL_PIPE_PUSH) + message(STATUS "MobileGL: MOBILEGL_PIPE_VERIFY=ON forces MOBILEGL_PIPE_PUSH ON for this configure") + set(MOBILEGL_PIPE_PUSH ON) +endif() + +# In a pull build the legacy arm is the ONLY arm, so the option cannot be off there. +# A normal variable, not a forced cache write, for the same reason as the two above. +if (NOT MOBILEGL_PIPE_PUSH AND NOT MOBILEGL_PIPE_LEGACY_MEMOS) + message(STATUS "MobileGL: MOBILEGL_PIPE_PUSH=OFF forces MOBILEGL_PIPE_LEGACY_MEMOS ON for this " + "configure: with nothing pushed it is the only arm there is") + set(MOBILEGL_PIPE_LEGACY_MEMOS ON) +endif() + +if (MOBILEGL_PIPE_PUSH) + message(STATUS "MobileGL: PipeInputs push ON, appending the MGPipe fill sources") + list(APPEND SOURCE_FILES + MobileGL/MG_Backend/MGPipe/PipeInputs.cpp + MobileGL/MG_Impl/Pipe/PipeFill.cpp + # P2's contract: the chunk table and its subset hash, the in-process applier, and + # the client's {slot, gen} allocator. All three are push-only, which is how the + # pull build gains no symbol from P2 (G1) - a declaration emits nothing. + MobileGL/MG_Pipe/MGPipeRenderStateSpans.cpp + MobileGL/MG_Pipe/PipeApply.cpp + MobileGL/MG_Impl/Pipe/SlotAllocator.cpp + # P4a's contract: the reflection-archive serializer over ProgramArtifacts.h's + # VisitFields tables. Push-only for the same G1 reason as the three above - in + # monolith the archive never crosses (create_shader_state hands the two structs over + # by pointer beside the record), so the codec is live code only in the VERIFY lane, + # where the applier serialises, deserialises and field-compares before storing. + MobileGL/MG_State/GLState/ProgramState/ProgramArtifactsCodec.cpp + # P5's contract (integrator ruling R-17, package c1): the MONOLITH arm of the + # client->wire routing - the thirty-seven adapters that install gMGPipeScreen / + # gMGPipeContext over the MGPipeApply* entry points, and the reply mailbox the four + # acceptance rows answer through. Push-only for the same G1 reason as the five above: + # the two tables are inline variables that are zero in a pull build and nothing there + # can reach a thunk. + MobileGL/MG_Pipe/PipeRoute.cpp + ) +endif() + +if (MOBILEGL_BUILD_DISAGGREGATED) + message(STATUS "MobileGL: disaggregated transport ON, appending MG_Remote sources") + list(APPEND SOURCE_FILES + MobileGL/MG_Remote/Transport/Ring.cpp + MobileGL/MG_Remote/Transport/Doorbell.cpp + MobileGL/MG_Remote/Transport/ShmSegment.cpp + # Both platform halves are listed unconditionally and each is empty on + # the other OS, so neither can rot behind an `if (WIN32)` nobody + # configures. + MobileGL/MG_Remote/Transport/ShmSegmentPosix.cpp + MobileGL/MG_Remote/Transport/ShmSegmentWin32.cpp + MobileGL/MG_Remote/Transport/FdPassing.cpp + MobileGL/MG_Remote/Transport/InProcessTransport.cpp + # Keeps MG_Util/Debug/Log.h - and through it the GL frontend's + # umbrella header - out of the header-only wire code (WireLog.h). + MobileGL/MG_Remote/Transport/WireLog.cpp + # ---- P5: the three new directories ------------------------------ + # Wire/ the G3 codec: MGPWireRec_* in and out of SEG_CMD, blobs and + # var-tails in and out of SEG_STAGE. [w1] + # Client/ the emitting role: session, the 69-slot emit table, the + # caps mirror. [c1] + # Server/ the applying role: session, the applier bridge onto the + # existing MGPipeApply* free functions, the apply thread. [v1] + # + # Every file below lands in P5 as a HEADER plus a .cpp of named + # Fatal stubs, so that all seven P5 packages compile and link on day + # one against signatures that cannot then move under them. A stub is + # MGLOG_F + std::abort, never a silent no-op: an unimplemented + # emitter that returns quietly is how a split lane runs monolith and + # goes green (ARCHITECTURE.md 10.3). + MobileGL/MG_Remote/CapsCodec.cpp + MobileGL/MG_Remote/Wire/PipeWireCodec.cpp + MobileGL/MG_Remote/Client/ClientSession.cpp + MobileGL/MG_Remote/Client/EmitTables.cpp + MobileGL/MG_Remote/Client/CapsMirror.cpp + # P5 c1: the client role's BackendObject. pActiveBackendObject holds one of these + # under split (table 3); the hook that installs it is v1's, in MG_Backend/Init.cpp. + MobileGL/MG_Remote/Client/BackendObject_Remote.cpp + # P5 c1, ruling R-17: the ENCODE TWIN of gMGPipeWireRecordApply - the thirty-seven + # emitters installed over the two generated tables, which is what stops every + # resource/CSO/texture/program record executing synchronously on the GL thread under + # inproc. The monolith arm of the same routing is MG_Pipe/PipeRoute.cpp, in the + # PIPE_PUSH list above, because it must exist in a push build that has no MG_Remote. + MobileGL/MG_Remote/Client/WireTables.cpp + # P5 b1's two: the conservative GPU-write set the client must build because all six + # MarkGpuWritten producers are on the server's side of the line, and the + # block-granularity persistent-map push that tier T2 makes mandatory. + MobileGL/MG_Remote/Client/GpuWritePending.cpp + MobileGL/MG_Remote/Client/PersistentMapTracker.cpp + MobileGL/MG_Remote/Server/ServerSession.cpp + MobileGL/MG_Remote/Server/PipeApplier.cpp + MobileGL/MG_Remote/Server/ServerLoop.cpp + ) +endif() + if (APPLE AND NOT MOBILEGL_IOS) list(APPEND SOURCE_FILES MobileGL/MG_Impl/CGLImpl/CGLImpl.cpp @@ -468,11 +648,29 @@ set(MOBILEGL_COMPILE_DEF -DASIO_NO_DEPRECATED ) +if (MOBILEGL_BUILD_DISAGGREGATED) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_BUILD_DISAGGREGATED=1) +endif() + +if (MOBILEGL_PIPE_PUSH) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_PUSH=1) +endif() +if (MOBILEGL_PIPE_VERIFY) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_VERIFY=1) +endif() +if (MOBILEGL_PIPE_LEGACY_MEMOS) + list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_PIPE_LEGACY_MEMOS=1) +endif() + message(STATUS "MOBILEGL_COMPILE_DEF=${MOBILEGL_COMPILE_DEF}") set(MOBILEGL_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/include ${CMAKE_SOURCE_DIR}/MobileGL + # The MGPipe boundary headers. They are reachable as through the + # line above too; this entry lets the client, the backends and MG_Remote spell them + # as once MG_Pipe stops being a leaf of the frontend tree. + ${CMAKE_SOURCE_DIR}/MobileGL/MG_Pipe ${spirv-tools_SOURCE_DIR} ${spirv-tools_SOURCE_DIR}/include ${spirv-tools_BINARY_DIR} @@ -483,6 +681,13 @@ set(MOBILEGL_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/3rdparty/asio/include ) +if (MOBILEGL_BUILD_DISAGGREGATED) + # Header-only runtime: an include path, no add_subdirectory, no link + # target, and above all no flatc in the build graph. protocol_generated.h + # is committed and regenerated by scripts/gen_protocol.py. + list(APPEND MOBILEGL_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/3rdparty/flatbuffers/include) +endif() + add_library(${CMAKE_PROJECT_NAME} SHARED ${SOURCE_FILES} ) @@ -699,3 +904,43 @@ endif() if (ANDROID AND MOBILEGL_BUILD_INTEGRATION_TEST) add_subdirectory(MobileGL/MG_IntegrationTest) endif() + +# --------------------------------------------------------------------------- +# P0 spike A: the Android delivery chain for a second native executable. +# +# The disaggregated design needs a server process on Android (PLAN-B.md §8.1, +# inheriting PLAN.md §11.1-§11.6). An APK's only exec-able install location is +# lib//, and the packager only puts a file there if it is named lib*.so - +# so a second executable has to be built with an .so name and exec'd out of +# getApplicationInfo().nativeLibraryDir. This target is the stub that proves the +# chain end to end: it is packaged like a library, exec'd from the app's own +# untrusted_app process, and writes a marker the parent reads back. +# +# Off by default and ANDROID-only, so no shipping configuration builds it. The +# trace flavour of the plugin APK turns it on (android-plugin/build.gradle). +# --------------------------------------------------------------------------- +if (ANDROID AND MOBILEGL_BUILD_SERVER_SPIKE) + add_executable(MobileGLServer + ${CMAKE_CURRENT_SOURCE_DIR}/tools/spikes/server_stub/main.cpp) + + # An executable that is named like a shared library still has to be a real + # PIE executable: Android has refused non-PIE executables since API 21, and + # the name alone does not change what the loader demands of the file. + set_target_properties(MobileGLServer PROPERTIES + PREFIX "lib" + SUFFIX ".so" + OUTPUT_NAME "MobileGLServer" + POSITION_INDEPENDENT_CODE ON) + target_compile_options(MobileGLServer PRIVATE -fPIE) + target_link_options(MobileGLServer PRIVATE -pie) + + # AGP packages what the external native build drops into the per-ABI output + # directory, and it selects by the .so extension. CMake puts executables in + # CMAKE_RUNTIME_OUTPUT_DIRECTORY, which is not the directory AGP hands to + # CMAKE_LIBRARY_OUTPUT_DIRECTORY, so point this target's runtime output at + # the library directory when the generator gave us one. + if (CMAKE_LIBRARY_OUTPUT_DIRECTORY) + set_target_properties(MobileGLServer PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_LIBRARY_OUTPUT_DIRECTORY}") + endif() +endif() diff --git a/MobileGL/Config.h b/MobileGL/Config.h index 8e9962f03..c4bb4db68 100644 --- a/MobileGL/Config.h +++ b/MobileGL/Config.h @@ -14,7 +14,7 @@ namespace MobileGL::MG_Config { inline const String ProjectName = "MobileGL"; inline const String CoreName = "MobileGL Core"; inline const String CoreVendor = "MobileGL-Dev (BZLZHH, Swung0x48, Tungsten)"; - inline const Version CoreVersion = {26, 8, 0, "-dev", VersionType::Development}; + inline const Version CoreVersion = {26, 9, 0, "-dev", VersionType::Development}; inline const VersionStringFormatAttrib DefaultVersionStringFormatAttrib = {2, 2, 0, true, true}; inline const Uint64 CacheVersion = 0; @@ -316,6 +316,240 @@ namespace MobileGL::MG_Config { // immune to the probe's verdict moving), and ForceOff is the negative control that // replays the driver's silence. QuirkOverride MagmaPrimGenQueryReroute = QuirkOverride::Auto; + // --- MGPipe (the disaggregation plan's explicit frontend/backend boundary) --- + // MOBILEGL_PIPE_PUSH: per-subsystem bitmask selecting which state the frontend + // PUSHES over MGPipe instead of leaving the backend to pull it out of GLContext. + // 0 - the only shipped value until the migration lands - is "pull everything", + // i.e. exactly today's behaviour, and is the default of a PULL build, where the + // knob is meaningless anyway. A PUSH build defaults to every subsystem migrated so + // far (MG_Pipe::kMGPipeSubsystemsMigratedAtP5e = 0x3fff), so MOBILEGL_PIPE_PUSH=0 in + // the environment is the all-pull control and 0x1fff (kMGPipeSubsystemsMigratedAtP4a) + // is the "everything before P5e" control P5e's A/B is run against - each phase's + // constant survives as the next phase's control, which is why none of them is ever + // edited. Accepts decimal or 0x-prefixed hex, and operators pass it as hex, so the + // bits are listed here (MG_Pipe/MGPipe.h owns them): + // 0x01 render state (create/bind_render_state + set_dynamic_state) + // 0x02 pixel pack 0x04 patch state 0x08 vertex attrib defaults + // 0x10 residual values 0x20 Espryt slots 0x40 Magma vertex input + // 0x80 resources (the resource_* family: the seven BufferBackendOps hooks) + // 0x100 vertex input (vertex elements / vertex buffers / index buffer) + // 0x200 framebuffer (set_framebuffer_state) - requires 0x400 + // 0x400 texture resources (texture + renderbuffer resource_*, + // set_texture_params) - requires 0x80 AND 0x800 + // (the built-in sampler CSO a set_texture_params record names is minted by + // the sampler family alone, ID-15; the four rows are MG_Impl/Pipe/PipeFill.cpp's + // kMGPipeP4aFamilyDependencies, mirrored bit for bit by Espryt's resolvers) + // 0x800 samplers (sampler CSO, sampler view, set_sampler_views / + // bind_sampler_states / set_shader_images) - requires 0x400 + // 0x1000 programs (shader CSO, set_draw/dispatch_program, global constants) + // 0x2000 buffer binding points (set_shader_buffers, the three indexed binding-point + // classes and the dirty bits 15/16/17) - requires 0x80 + // (every MGPBufferRange::Res names a Buffer handle; P5e) + // A dependency that is not met is REFUSED with one ERROR naming both bits and the + // family runs its legacy arm; it is never half-run. + // 1<<63 NOT a subsystem, a BEHAVIOUR: turn OFF client-side content addressing of + // CSOs, so every pipeline-version change mints a fresh CSO and the map is + // never probed. The negative control the CSO design is measured against. + Uint64 PipePush = 0; + // MOBILEGL_PIPE_VERIFY: per-draw, per-FIELD shadow comparison of the pushed state + // against a snapshot taken from GLContext the old way, printing the first field + // that differs and the draw serial. Roughly 5-10x slower and never shipped; it is + // the semantic gate that replaces byte identity, and it catches the dangerous + // direction - a dirty bit that fires too RARELY - which no purity gate can see. + Bool PipeVerify = false; +#if MOBILEGL_PIPE_PUSH + // The three knobs of the MOBILEGL_PIPE_VERIFY build (P1 brief D2). Compiled only + // under MOBILEGL_PIPE_PUSH so the pull build's FeaturesTable does not change size. + // MOBILEGL_PIPE_VERIFY_FATAL: the first divergence aborts (default). 0 logs and + // counts instead, for triage and for the lane that must survive to read its own + // log. Tri-state parse like PipeLegacyMemos: only an explicit falsy value turns it + // off. + Bool PipeVerifyFatal = true; + // MOBILEGL_PIPE_VERIFY_CORRUPT: a field name from kMGPipeInputFieldNames[]; the + // comparator perturbs that field in the SNAPSHOT arm before the entry compare, so a + // green verify run goes red naming it (negative control A). Unknown name is + // Fatal{PipeVerifyBadKnob}. + String PipeVerifyCorrupt; + // MOBILEGL_PIPE_POISON_OMIT: :; the filler skips the STAMP (not + // the value) of that field for that verb, an omission indistinguishable from a + // forgotten FillPoints.def row, so that verb's read of it is + // Fatal{UnmigratedPipeInput} (negative control B). Unknown name is + // Fatal{PipeVerifyBadKnob}. + String PipePoisonOmit; + // MOBILEGL_PIPE_HANDLE_ABA_CONTROL (negative control C, P2 brief D18): replace the + // OBJECT IDENTITY in every DirectVulkan vertex-input memo key with a constant, on + // whichever arm the run is on - the pre-handle (address, lifetime id) pair AND the + // handle arm's {slot, gen} generation - so a replacement object inherits its dead + // predecessor's resolved vertex bindings and HandleRecycleScenario.AbaControl asserts + // the WRONG pixels. That is what proves the reproducer still reproduces. D18 wrote + // this as "hash the raw BufferObject* instead of its lifetime id"; measured, the heap + // block is never handed back, so that spelling collided with nothing and the control + // went vacuous - see MagmaPipeArms.h's MagmaPipeAbaControlDefeatsIdentity for the + // measurement and for what the control still leaves standing. Under + // MOBILEGL_PIPE_PUSH only, so it cannot exist in a shipping pull build. + Bool PipeHandleAbaControl = false; +#endif + // MOBILEGL_PIPE_STATS: dump the boundary counters (bytes, calls, roundtrips, + // texture pulls, upload shapes, residual-block bytes, index mirror bytes). + Bool PipeStats = false; + // MOBILEGL_PIPE_LEGACY_MEMOS: keep the pre-handle registries and TwinLookupMemos + // alive so the first handle waves have a real old-versus-new arm to be compared + // against. ON by default for the whole migration window, deleted with the pull + // path itself. + Bool PipeLegacyMemos = true; + // MOBILEGL_PIPE_TEXEL_RETAIN_MB: LRU budget for texels retained against a + // server-initiated texture re-send. Default 0, i.e. OFF: MipmapStorage already + // holds a complete CPU shadow, so this cache buys latency, never correctness. + Uint32 PipeTexelRetainMb = 0; + // MOBILEGL_PIPE_INDEX_MIRROR_MB: budget for the server-side index host mirror, + // which is what lets primitive-restart rewriting and multi-draw flattening stay on + // the server without shipping index bytes per draw. Over budget it degrades to + // per-draw staging, counted separately in the stats. + Uint32 PipeIndexMirrorMb = 64; + // MOBILEGL_PIPE_STATS_PERIOD: frames per boundary-counter summary line. 120 is the + // steady-state cadence; the device retrace harness never reaches the teardown dump + // and a trimmed fixture (create-indirect) is shorter than 120 frames, so a run that + // needs its numbers at all sets this low enough to land at least one window. + Uint32 PipeStatsPeriod = 120; + // MOBILEGL_PIPE_STATS_FILE: where the boundary counters' teardown JSON dump goes. + // Empty (the default) means no dump; the per-120-frame summary line still goes to + // the log whenever PipeStats is on, so a device run needs no writable path. + String PipeStatsFile; }; extern FeaturesTable Features; + + // --------------------------------------------------------------------------------- + // P5: the transport selector and the MOBILEGL_IPC_* family (ARCHITECTURE.md 16, 附 A) + // --------------------------------------------------------------------------------- + // + // MOBILEGL_TRANSPORT = monolith | inproc | spawn | unix: | pipe:. + // + // WHY `Transport` IS NOT A FeaturesTable MEMBER. ARCHITECTURE.md:580 requires that with + // MOBILEGL_BUILD_DISAGGREGATED=OFF it be a `constexpr Monolith`, so that the single hook + // in MG_Backend/Init.cpp compiles away entirely rather than becoming a branch nobody can + // take. A FeaturesTable member is a runtime field in every build, which is the opposite + // of that; it would also resize MG_Config::Features and break G1 (the pull build's + // symbol set must not move) for the same reason the MOBILEGL_PIPE_VERIFY knobs above sit + // behind their own #if. + // + // ONE CONSEQUENCE, STATED SO IT IS NOT REDISCOVERED: in a build without the option, + // MOBILEGL_TRANSPORT=inproc is ACCEPTED BY THE ENVIRONMENT AND SILENTLY IGNORED - the + // parser below does not exist to complain about it, and putting a complaint in the + // unconditional part of ConfigLoader would move a pull-build symbol. That is the exact + // shape of "the split lane ran monolith and went green", so the gate against it is a + // BUILD-level check, not a runtime one: `nm --defined-only libMobileGL.so | grep -i + // MG_Remote` must be non-empty in build-split (CONTRACT-P5.md table 3, and the CI job + // P5 adds beside build-linux-verify). + enum class TransportMode : Uint8 { + Monolith = 0, // today's in-library backend; no MG_Remote object is constructed + InProcess = 1, // P5: a real apply thread in this process, over the same G3 codec + Spawn = 2, // P6: fork/exec MobileGLServer, socketpair + UnixSocket = 3,// P6: connect to an existing AF_UNIX endpoint (Endpoint = ) + NamedPipe = 4, // P6: Windows named pipe (Endpoint = ) + }; + +#if MOBILEGL_BUILD_DISAGGREGATED + // Parsed once by MG_ConfigLoader::Init(). Defaults to Monolith even here: building the + // transport in is not the same as using it, and every existing lane of a build-split + // must keep running monolith unless it is asked for one. + extern TransportMode Transport; + // The of `unix:` / the of `pipe:`. Empty for the other three modes. + extern String TransportEndpoint; + + // The MOBILEGL_IPC_* family. A separate table rather than more FeaturesTable members, + // for the G1 reason above and because every field here is meaningless without the + // transport: a build that cannot reach the MG_Remote code cannot honour one of them. + // + // P5 lands exactly the knobs P5's own packages read. A later phase's knob is added HERE, + // through the integrator, and not invented at its call site - ARCHITECTURE.md:615 holds + // the full planned inventory (PRESENT_CREDIT, POLL_ESCALATE, SHADOW_SHM, + // INLINE_PAYLOADS, TRACE, ATTACH, RESPAWN, IDLE_EXIT_S), and every one of those belongs + // to P6 or later. + struct IpcTable { + // MOBILEGL_IPC_SERVER_PATH: where to find libMobileGLServer. P6 consumes it; P5 + // lands the parse because t1's ctest ENVIRONMENT blocks and add_trace_replay_test's + // SPLIT variant already carry it, and an environment variable that nothing parses is + // indistinguishable from one that is parsed and ignored. + String ServerPath; + // MOBILEGL_IPC_RING_MB: SEG_CMD size. A RECORD MAY BE AT MOST HALF OF THIS + // (RingProducer::MaxRecordBytes), so 8 MiB caps one record at 4 MiB; R-10 makes the + // codec publish a max-record-bytes counter rather than assume that is enough. + Uint32 RingMb = 8; + // MOBILEGL_IPC_STAGE_MB: SEG_STAGE size. Every blob and every var-tail's bytes live + // here (R-10: no chunking in P5, so nothing may exceed it). + Uint32 StageMb = 32; + // MOBILEGL_IPC_SPIN_US: spin before parking on a doorbell, either direction. + Uint32 SpinUs = 50; + // MOBILEGL_IPC_PERSISTENT_BLOCK_KB: block granularity of the persistent-map push. + // 0 IS A NEGATIVE CONTROL, NOT "unlimited": it disables the push, and + // PersistentCoherentMapScenario must go RED under it (exit gate E3(a)). + Uint32 PersistentBlockKb = 64; + // MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS: 1 = the persistent-map push ships only + // blocks whose xxHash64 changed since the last push, instead of the whole mapped + // range every verb. 0 restores the whole-range push (A/B control). + Uint32 PersistentHashSuppress = 1; + // MOBILEGL_IPC_BATCH_WAITS: 1 = value-class records (kCtxState / kCtxCso / kCtxObject + // with no reply slot) are published without waiting for their own apply; the barrier + // is taken at the next pull-reading verb (kCtxVerb syncs, queries, screen rows, and + // every reply-slot row), which is the only place BARRIER-PULLED fields are read. 0 + // restores the per-record barrier of R-1. + Uint32 BatchWaits = 1; + // MOBILEGL_IPC_ADOPT_TIER: 2 = emulate (client keeps the shadow and pushes), which + // is the only tier P5 implements and the reason persistent-map-push can be non-zero + // at all (R-6). 0 and 1 parse and are Fatal at use with "P11"; they exist now so the + // negative control has a spelling the day P11 writes it. + Uint32 AdoptTier = 2; + // MOBILEGL_IPC_VERB_BARRIER: 1 = the client blocks at every verb boundary until + // appliedSeq reaches its emitSeq (R-1). 0 is the negative control: it is EXPECTED to + // be red, because 31 of the 63 PipeInputs fields are still pulled from a live + // GLContext by the client's residual fill and a free-running queue lets the server + // read a FUTURE value of them. + Uint32 VerbBarrier = 1; + // MOBILEGL_IPC_RUN_AHEAD (P5e, MG_Remote/CONTRACT-P5E.md §1): 1 = after publishing an + // UNBARRIERED record the client returns immediately instead of waiting for its apply. + // It is one half of a conjunction and never a switch on its own - the client arms + // run-ahead only when the server also publishes kCapRunAheadApply, so on Magma, and on + // Espryt before the P5e integration commit, 1 means exactly what 0 means and logs once + // saying so. + // + // 0 IS THE A/B CONTROL AND NOT A NEGATIVE ONE: the server code, the fill decision and + // every record are identical on both arms, and the only difference is whether the + // client waits. That is what makes "is the picture the same" a question about the wait + // rule alone. MOBILEGL_IPC_VERB_BARRIER=0 keeps its own meaning and stays the + // lockstep arm's negative control; under run-ahead it is the one that must go red, at + // the first barriered row's stale pull. + // + // Forced to 0 by MOBILEGL_PIPE_VERIFY, beside BatchWaits: the comparator needs a + // client-filled gPipeInputs block for every verb, and run-ahead is precisely the + // arm that stops filling it. + Uint32 RunAhead = 1; + // MOBILEGL_IPC_PRESENT_CREDIT (P5e, ruling 4 / ID-92): how many presents the client may + // have in flight before it waits for a swap to come back. 1 = the client publishes + // frame N+1's records while the server applies and swaps frame N - one frame of + // overlap, at most one frame of added latency - and the CREDIT, never the ring's bytes, + // is what paces a run-ahead client. 2 is a device MEASUREMENT arm: it buys no CPU on a + // client that is already CPU-bound and costs a frame of latency, which is why the + // default is 1 and not "as deep as the ring". + Uint32 PresentCredit = 1; + // MOBILEGL_IPC_STRICT_ERRORS: promote a BARRIER-PULLED field read - and, in a split + // build, the seven sticky forwards that are otherwise exempt - from "count it in + // rsp" to Fatal (R-7.3). + Bool StrictErrors = false; + // MOBILEGL_IPC_AUDIT: after a record retires, the server fills the SEG_STAGE bytes + // it referenced with 0xDD (R-2.5). This is the ONLY mechanical control that an + // inproc implementation did not quietly keep using a pointer past its lifetime. + Bool Audit = false; + // MOBILEGL_IPC_SERVER_AFFINITY: `auto` (the default, big-core detection borrowed + // from ShaderCompilePool), `off`, or an explicit CPU mask. Kept as the raw string + // because the resolved mask is logged by whoever starts the apply thread, and the + // string is what an operator typed. + String ServerAffinity = "auto"; + }; + extern IpcTable Ipc; +#else + // The whole point: in a build without MG_Remote this folds at compile time, so + // `if (MG_Config::Transport != MG_Config::TransportMode::Monolith)` in Init.cpp is a + // discarded statement and the pull build gains no symbol, no branch and no byte. + inline constexpr TransportMode Transport = TransportMode::Monolith; +#endif } // namespace MobileGL::MG_Config diff --git a/MobileGL/ConfigLoader.cpp b/MobileGL/ConfigLoader.cpp index 478093846..5d4d6a9d0 100644 --- a/MobileGL/ConfigLoader.cpp +++ b/MobileGL/ConfigLoader.cpp @@ -7,6 +7,12 @@ // End of Source File Header #include "Config.h" +#if MOBILEGL_PIPE_PUSH +// For kMGPipeSubsystemsMigratedAtP5e, the push build's PipePush default (the P2, P3a and P4a +// constants beside it are the phase-by-phase controls, not the default). Push-only, so the +// pull build's translation unit is unchanged. +#include +#endif #include #include @@ -19,6 +25,15 @@ namespace MobileGL::MG_Config { // Zero/default-initialized at static-init time (all fields have constexpr-friendly // defaults), so it is safe to read even if MG_ConfigLoader::Init has not run yet. FeaturesTable Features; +#if MOBILEGL_BUILD_DISAGGREGATED + // Same contract, and for the same reason: MG_Backend::Init() reads Transport, and a + // build order that put it before MG_ConfigLoader::Init() must see Monolith rather than + // a torn enum. Defined only here - in a pull build Config.h makes Transport a constexpr + // and there is nothing to define. + TransportMode Transport = TransportMode::Monolith; + String TransportEndpoint; + IpcTable Ipc; +#endif } // namespace MobileGL::MG_Config namespace MobileGL::MG_ConfigLoader { @@ -159,6 +174,38 @@ namespace MobileGL::MG_ConfigLoader { return static_cast(parsedValue); } + // Same contract as QueryEnvUint32, over 64 bits and accepting an explicit 0x prefix: the + // one consumer is a subsystem BITMASK, and a bitmask written in decimal is unreadable. + // Decimal otherwise - never strtoull's base 0, whose "leading zero means octal" rule + // silently read MOBILEGL_PIPE_PUSH=010 as 8 - and a '-' anywhere is rejected rather than + // wrapped, which strtoull would otherwise do without complaint (-1 -> every bit set). + inline Uint64 QueryEnvUint64(const String& key, Uint64 defaultValue) { + auto it = acceptedEnvVariablesMap->find(key); + if (it == acceptedEnvVariablesMap->end()) { + return defaultValue; + } + + const String& value = it->second; + const char* text = value.c_str(); + int base = 10; + if (value.size() > 2 && text[0] == '0' && (text[1] == 'x' || text[1] == 'X')) { + text += 2; + base = 16; + } + char* parseEnd = nullptr; + errno = 0; + const bool negative = value.find('-') != String::npos; + const unsigned long long parsedValue = negative ? 0 : std::strtoull(text, &parseEnd, base); + if (negative || parseEnd == text || *parseEnd != '\0' || errno == ERANGE) { + MGLOG_W("Config: Ignoring invalid env variable %s='%s'; expected a non-negative integer " + "(decimal, or 0x-prefixed hexadecimal), using default %llu", + key.c_str(), value.c_str(), static_cast(defaultValue)); + return defaultValue; + } + + return static_cast(parsedValue); + } + inline void InitFeatures() { auto& features = MG_Config::Features; features.DisableTimerQuery = QueryEnvFlag("MOBILEGL_DISABLE_TIMERQUERY"); @@ -207,6 +254,40 @@ namespace MobileGL::MG_ConfigLoader { features.EsprytWidenPacked16Storage = QueryEnvQuirkOverride("MOBILEGL_ESPRYT_WIDEN_PACKED16_STORAGE"); features.MagmaPrimGenQueryReroute = QueryEnvQuirkOverride("MOBILEGL_MAGMA_PRIMGEN_QUERY_REROUTE"); + // MGPipe. Nothing here needs adding to an allow-list: InitializeAcceptedEnvVariables + // accepts every MOBILEGL_ / LIBGL_ prefixed variable in the environment, so a name + // that starts with MOBILEGL_ is visible to these queries by construction. +#if MOBILEGL_PIPE_PUSH + // A push build with the knob unset runs every subsystem migrated so far, so the + // shipped path is the one the gates measure; MOBILEGL_PIPE_PUSH=0 in the + // environment is the all-subsystems-pull control that reproduces P1 exactly, and + // kMGPipeSubsystemsMigratedAtP3a (0x1ff) is the phase-by-phase control - P4a's four + // subsystems off, everything P3a landed still on. + features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", MG_Pipe::kMGPipeSubsystemsMigratedAtP5e); +#else + // Meaningless in a pull build: there is nothing to push. Config.h documents 0 as + // "pull everything" and that stays literally true. + features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0); +#endif + features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY"); +#if MOBILEGL_PIPE_PUSH + // Defaults ON: read as a tri-state so only an explicitly falsy value turns it off. + features.PipeVerifyFatal = + QueryEnvQuirkOverride("MOBILEGL_PIPE_VERIFY_FATAL") != MG_Config::QuirkOverride::ForceOff; + QueryEnvVariable("MOBILEGL_PIPE_VERIFY_CORRUPT", features.PipeVerifyCorrupt, ""); + QueryEnvVariable("MOBILEGL_PIPE_POISON_OMIT", features.PipePoisonOmit, ""); + features.PipeHandleAbaControl = QueryEnvFlag("MOBILEGL_PIPE_HANDLE_ABA_CONTROL"); +#endif + features.PipeStats = QueryEnvFlag("MOBILEGL_PIPE_STATS"); + // Defaults ON, so the flag has to be read as a tri-state rather than as a plain + // truthy check: unset must keep the memos, and only an explicitly falsy value may + // drop them. + features.PipeLegacyMemos = + QueryEnvQuirkOverride("MOBILEGL_PIPE_LEGACY_MEMOS") != MG_Config::QuirkOverride::ForceOff; + features.PipeTexelRetainMb = QueryEnvUint32("MOBILEGL_PIPE_TEXEL_RETAIN_MB", 0, 0, 4096); + features.PipeIndexMirrorMb = QueryEnvUint32("MOBILEGL_PIPE_INDEX_MIRROR_MB", 64, 0, 4096); + features.PipeStatsPeriod = QueryEnvUint32("MOBILEGL_PIPE_STATS_PERIOD", 120, 1, 1000000); + QueryEnvVariable("MOBILEGL_PIPE_STATS_FILE", features.PipeStatsFile, ""); } inline void InitBackendType() { @@ -225,12 +306,128 @@ namespace MobileGL::MG_ConfigLoader { #undef ENTRY } +#if MOBILEGL_BUILD_DISAGGREGATED + // MOBILEGL_TRANSPORT = monolith | inproc | spawn | unix: | pipe: + // (ARCHITECTURE.md:583). Shaped after InitBackendType above: an exact-name table, then + // one fallback that names what it did instead. The two prefixed forms are the only + // reason this is not literally that function's ENTRY macro. + // + // spawn / unix: / pipe: PARSE AND THEN REFUSE. They are P6's, and the refusal is NAMED + // rather than silent, because the failure this avoids is a P6 lane that set + // MOBILEGL_TRANSPORT=spawn, fell back to monolith, and went green on the wrong arm. + // The mode is left at Monolith so nothing half-initializes. + inline void InitTransport() { + String value; + QueryEnvVariable("MOBILEGL_TRANSPORT", value, "monolith"); + String lowered = value; + std::transform(lowered.begin(), lowered.end(), lowered.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + + MG_Config::TransportEndpoint.clear(); + if (lowered.empty() || lowered == "monolith") { + MG_Config::Transport = MG_Config::TransportMode::Monolith; + return; + } + if (lowered == "inproc") { + MG_Config::Transport = MG_Config::TransportMode::InProcess; + MGLOG_I("Config: MOBILEGL_TRANSPORT=inproc - the MGPipe record stream crosses a real " + "ring to an apply thread"); + return; + } + // The three P6 forms. Recognised precisely, so the diagnostic can say "not yet" + // rather than "unknown", which are different bugs on the operator's side. + if (lowered == "spawn" || lowered.compare(0, 5, "unix:") == 0 || + lowered.compare(0, 5, "pipe:") == 0) { + MGLOG_E("Config: MOBILEGL_TRANSPORT='%s' names a transport P6 implements and P5 does " + "not; staying on monolith. This run is NOT a split run.", + value.c_str()); + MG_Config::Transport = MG_Config::TransportMode::Monolith; + return; + } + MGLOG_W("Config: Ignoring invalid env variable MOBILEGL_TRANSPORT='%s'; expected " + "monolith|inproc|spawn|unix:|pipe:, using monolith", + value.c_str()); + MG_Config::Transport = MG_Config::TransportMode::Monolith; + } + + // The MOBILEGL_IPC_* family (Config.h IpcTable). Parsed unconditionally rather than only + // when Transport != Monolith: a knob that silently means nothing on one arm of an A/B is + // how an A/B stops being one, and the ranges below are the diagnostics. + inline void InitIpc() { + auto& ipc = MG_Config::Ipc; + QueryEnvVariable("MOBILEGL_IPC_SERVER_PATH", ipc.ServerPath, ""); + // Both ring floors are 1 MiB, not 0: a ring caps ONE record at half its size, and + // the catalogue's largest fixed payload (MGPFramebufferState, 304 bytes) plus a + // create_shader_state archive already needs far more than a toy ring. The ceilings + // are sanity, not policy. + ipc.RingMb = QueryEnvUint32("MOBILEGL_IPC_RING_MB", 8, 1, 1024); + ipc.StageMb = QueryEnvUint32("MOBILEGL_IPC_STAGE_MB", 32, 1, 4096); + ipc.SpinUs = QueryEnvUint32("MOBILEGL_IPC_SPIN_US", 50, 0, 1000000); + // 0 is admitted ON PURPOSE and is the negative control of exit gate E3(a): it turns + // the persistent-map push OFF, and PersistentCoherentMapScenario must go red. + ipc.PersistentBlockKb = QueryEnvUint32("MOBILEGL_IPC_PERSISTENT_BLOCK_KB", 64, 0, 65536); + // Whole-range push is the 0 arm; with it on (default) only blocks whose + // xxHash64 changed since the last push are shipped. + ipc.PersistentHashSuppress = QueryEnvUint32("MOBILEGL_IPC_PERSISTENT_HASH_SUPPRESS", 1, 0, 1); + ipc.BatchWaits = QueryEnvUint32("MOBILEGL_IPC_BATCH_WAITS", 1, 0, 1); + // The verify harness compares the pushed block against the applier per verb; a + // batched queue lets the comparer read a supplied field mid-apply, which is a + // torn read rather than a divergence. The batch is therefore off whenever the + // shadow comparer is armed. + if (MG_Config::Features.PipeVerify) ipc.BatchWaits = 0; + // 2 is the only tier P5 implements (R-6). 0 and 1 parse here and are refused at the + // point of use, which is where the "P11" in the message belongs. + ipc.AdoptTier = QueryEnvUint32("MOBILEGL_IPC_ADOPT_TIER", 2, 0, 2); + ipc.VerbBarrier = QueryEnvUint32("MOBILEGL_IPC_VERB_BARRIER", 1, 0, 1); + // P5e (MG_Remote/CONTRACT-P5E.md §1). The wait rule's A/B, parsed here like every + // other IPC knob and armed only where the server publishes kCapRunAheadApply. + ipc.RunAhead = QueryEnvUint32("MOBILEGL_IPC_RUN_AHEAD", 1, 0, 1); + // ... and forced OFF by the verify harness for BatchWaits' reason, one step further: + // run-ahead's whole point is that the client stops filling gPipeInputs for an + // unbarriered record, and the comparator has nothing left to compare when it does. + if (MG_Config::Features.PipeVerify) ipc.RunAhead = 0; + // The present credit (ruling 4). 1 is one frame of overlap; 8 is the ceiling because a + // deeper queue buys nothing on a CPU-bound client and pays for it in latency. 0 is NOT + // admitted: a credit of zero would mean "publish no present at all". + ipc.PresentCredit = QueryEnvUint32("MOBILEGL_IPC_PRESENT_CREDIT", 1, 1, 8); + ipc.StrictErrors = QueryEnvFlag("MOBILEGL_IPC_STRICT_ERRORS"); + ipc.Audit = QueryEnvFlag("MOBILEGL_IPC_AUDIT"); + QueryEnvVariable("MOBILEGL_IPC_SERVER_AFFINITY", ipc.ServerAffinity, "auto"); + + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + // One line, on the arm where these numbers decide behaviour, because every one of + // them is a number a bug report has to quote. + MGLOG_I("Config: IPC ring=%uMiB stage=%uMiB spin=%uus persistent-block=%uKiB " + "adopt-tier=%u verb-barrier=%u run-ahead=%u present-credit=%u strict=%d " + "audit=%d affinity='%s'", + ipc.RingMb, ipc.StageMb, ipc.SpinUs, ipc.PersistentBlockKb, ipc.AdoptTier, + ipc.VerbBarrier, ipc.RunAhead, ipc.PresentCredit, + static_cast(ipc.StrictErrors), static_cast(ipc.Audit), + ipc.ServerAffinity.c_str()); + if (ipc.VerbBarrier == 0) { + MGLOG_W("Config: MOBILEGL_IPC_VERB_BARRIER=0 is the R-1 NEGATIVE CONTROL and is " + "expected to fail: the client still pulls 31 of 63 PipeInputs fields from a " + "live GLContext, so an unbarriered queue lets the server read future values"); + } + if (ipc.PersistentBlockKb == 0) { + MGLOG_W("Config: MOBILEGL_IPC_PERSISTENT_BLOCK_KB=0 is the E3(a) NEGATIVE CONTROL: " + "the persistent-map push is OFF and a coherent-map scenario must go red"); + } + } +#endif + void Init() { MGLOG_D("Loading configuration from environment variables..."); InitializeAcceptedEnvVariables(); InitBackendType(); InitFeatures(); +#if MOBILEGL_BUILD_DISAGGREGATED + // After InitFeatures, so the one line InitIpc logs is the last word on this run's + // configuration, and before the accepted-env map is destroyed just below. + InitTransport(); + InitIpc(); +#endif // Destroy the map since we won't need it anymore acceptedEnvVariablesMap.reset(); diff --git a/MobileGL/Init.cpp b/MobileGL/Init.cpp index 68870532f..2436d792b 100644 --- a/MobileGL/Init.cpp +++ b/MobileGL/Init.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,20 @@ namespace MobileGL { if (logLifecycle) { MGLOG_I("MobileGL closing..."); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5: the split roles come down FIRST - ARCHITECTURE.md:537's order puts the whole + // of it before MobileGL::Destroy(), and this function IS MobileGL::Destroy. A no-op + // in a monolith RUN; absent from a monolith BUILD, because G1 admits no new pull + // symbol and no resized one (the first version called it unconditionally and moved + // DestroyImpl by 32 bytes). See BackendObjects.h for why the position rather than + // the call is the load-bearing part. + MG_Backend::ShutdownSplitRoles(); +#endif + // Before any subsystem the counters name goes away, and before the last frame's + // numbers can be lost: emits the final summary line and, when + // MOBILEGL_PIPE_STATS_FILE is set, the JSON dump. A no-op when the counters are + // off, and idempotent. + MG_Util::PipeStats::Shutdown(); // First, before anything else is torn down. In-flight compile/link jobs own // their own inputs and are safe against everything below EXCEPT glslang's // process globals and the TShader/TProgram objects hanging off pGLContext, @@ -102,6 +117,10 @@ namespace MobileGL { MGLOG_I("Initializing MobileGL..."); MG_ConfigLoader::Init(); MGLOG_I("Config loaded"); + // Immediately after the config load and before anything can count: the MGPipe + // boundary counters latch their enable flag here, so every counting site in the + // two backends is a load of an already-settled global for the rest of the run. + MG_Util::PipeStats::Init(); MG_State::Init(); MGLOG_D("MG_State initialized"); MG_Backend::Init(); diff --git a/MobileGL/MG_Backend/BackendObject.h b/MobileGL/MG_Backend/BackendObject.h index d528d820f..70cd3c785 100644 --- a/MobileGL/MG_Backend/BackendObject.h +++ b/MobileGL/MG_Backend/BackendObject.h @@ -192,9 +192,23 @@ namespace MobileGL { void (*MemoryBarrierByRegion)(GLbitfield barriers); void (*BindImageTexture)(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format); + // The ONLY indexed query that is genuinely a backend one, and only for the pnames + // MG_Impl/GLImpl/Getter/GL_Getter.cpp does not already own. Every indexed pname that + // names FRONTEND state - the indexed buffer bindings, the per-unit texture/sampler + // bindings, the image-unit bindings, the viewport rectangles, the indexed capabilities + // - is answered in GL_Getter::GetIntegeri_v and never reaches this entry; the + // 64-bit and float/double widths are derived there from the same answer, which is why + // no GetInteger64i_v/GetFloati_v/GetDoublei_v table entry exists. In practice this + // leaves GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE (also asked directly by + // MG_Util/ShaderTranspiler/CompileEnv.cpp) plus whatever pname the frontend has no + // case for at all. void (*GetIntegeri_v)(GLenum target, GLuint index, GLint* data); - void (*GetInteger64i_v)(GLenum target, GLuint index, GLint64* data); - void (*GetProgramiv)(GLuint program, GLenum pname, GLint* params); + // There is deliberately NO GetProgramiv entry: glGetProgramiv describes the program + // the APPLICATION wrote - link status, the transform-feedback mode, the compute local + // size - all of which are frontend link artifacts on ProgramObject, and + // MG_Impl/GLImpl/Program/GL_Program.cpp answers every one of them from there. Asking a + // backend would mean asking about a DIFFERENT program (a SPIRV-Cross-generated ESSL + // one, or a SPIR-V module), in a namespace the application never sees. // The GL program interface (glGetProgramInterfaceiv / glGetProgramResource*) is NOT // a backend query: it describes the program the application wrote, in the // application's namespace, which neither backend program is in. It is answered @@ -364,6 +378,19 @@ namespace MobileGL { Int MaxFragmentShaderStorageBlocks = 8; Int MaxComputeUniformBlocks = 12; Int MaxComputeWorkGroupInvocations = 128; + // GL_MAX_COMPUTE_WORK_GROUP_COUNT / GL_MAX_COMPUTE_WORK_GROUP_SIZE, one value per + // axis. These six, with the invocations limit above, are the only indexed limits a + // backend genuinely OWNS - the device answers them (glGetIntegeri_v on DirectGLES, + // VkPhysicalDeviceLimits::maxComputeWorkGroupCount/Size on DirectVulkan) - and so + // the only ones that survive the retirement of the GetIntegeri_v table entry: they + // cross the MGPipe boundary inside MGPCaps, by inclusion of this struct (plan B + // section 4.4.1). Every other indexed pname names frontend state. RAW driver + // answers, like the invocations limit: GL_Getter and the compile environment floor + // them at the shared MIN_COMPUTE_WORK_GROUP_* minimums themselves. The defaults are + // the GL 4.3 core minimums (table 23.60) and describe the no-backend case, as + // MaxClipDistances' does. + Int MaxComputeWorkGroupCount[3] = {65535, 65535, 65535}; + Int MaxComputeWorkGroupSize[3] = {1024, 1024, 64}; Int MaxShaderStorageBufferBindings = 8; Int MaxTextureBufferSize = 65536; // GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT; 1 means the offset is unconstrained. diff --git a/MobileGL/MG_Backend/BackendObjects.h b/MobileGL/MG_Backend/BackendObjects.h index 1a1488249..8d71c6743 100644 --- a/MobileGL/MG_Backend/BackendObjects.h +++ b/MobileGL/MG_Backend/BackendObjects.h @@ -15,4 +15,29 @@ namespace MobileGL::MG_Backend { extern UniquePtr& pActiveBackendObject; extern GlobalBackendFunctionsTable gBackendFunctionsTable; + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5 v1. The counterpart of Init()'s single split hook, and a NO-OP in every run that is + // not split. It exists ONLY in a split build: G1 admits no new symbol in the pull build, + // so MobileGL/Init.cpp's call sits under the same #if rather than calling a no-op. + // + // IT MUST RUN FIRST, BEFORE ANYTHING ELSE IN DestroyImpl. ARCHITECTURE.md:537's order is + // publish -> the server drains and acks -> stop the apply thread (Kill, then join) -> close + // the transport -> the client drains the compile pool -> MobileGL::Destroy() -> release the + // sync/query handles; DestroyImpl IS MobileGL::Destroy, so everything before that arrow has + // to happen at its top. Two consequences are load-bearing rather than tidy: + // + // * the apply thread is JOINED before PipeStats::Shutdown() dumps, so nothing is writing + // a counter while the final line is produced; + // * the apply thread is joined before pActiveBackendObject.reset(), so the server's own + // BackendObject - which is NOT that global (table 3) - is destroyed on the thread that + // owns the context, by ServerLoop::Stop, rather than on the app thread. + // + // The sync/query registries stay where they are, drained at MobileGL/Init.cpp:62 and :67 + // BEFORE pActiveBackendObject.reset(). ARCHITECTURE.md:537 puts them after + // MobileGL::Destroy(); the two are only reconcilable if a split sync handle is + // client-minted and needs no backend call, which is P10's. CONTRACT-P5 4 flags this as a + // KNOWN OPEN ITEM and asks v1 to record which way it went: P5 keeps today's order. + void ShutdownSplitRoles(); +#endif } // namespace MobileGL::MG_Backend diff --git a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp index c9905508c..7a38bce02 100644 --- a/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/BackendObject_DirectGLES.cpp @@ -32,6 +32,54 @@ namespace MobileGL::MG_Backend::DirectGLES { return draw == EGL_NO_SURFACE && read == EGL_NO_SURFACE && ctx == EGL_NO_CONTEXT; } +#if MOBILEGL_BUILD_DISAGGREGATED + // ID-54 / ID-67 (v1, under ID-52/ID-59's grant for this file). Which VIRTUAL (dpy, draw, + // read, ctx) the process's one native ES context is currently bound FOR, on the apply + // thread. DirectGLES has one native context and one native surface (g_Context, g_Surface) + // whatever virtual handles the client uses, so "is a native eglMakeCurrent needed" is never + // "is the native triple different" - it is "did the VIRTUAL context change": + // DirectGLES::MakeCurrent is also where the seven caches that describe the frontend context + // are invalidated, and a different virtual context needs them invalidated even though the + // driver binds the same triple. ID-67: a make-current with a DIFFERENT tuple is a real + // native bind (and a caps republish, ServerLoop's half); an IDENTICAL one is neither. + // + // Three states. NotBound: the next bind is native. FreshFromSurfaceCreation: the surface's + // own creation (InitPbufferSurface / InitWindowSurface) bound natively and invalidated, and + // no virtual tuple has claimed that bind yet - the first tuple adopts it, which is the + // "2 -> 1 native binds per process" of round 3. BoundForTuple: bound and invalidated for + // the recorded tuple; only that exact tuple may skip. Process-wide like the native state it + // mirrors; under split exactly one DirectGLES object exists (the server's), and only the + // apply thread reaches these. + enum class NativeBindState : Uint8 { NotBound, FreshFromSurfaceCreation, BoundForTuple }; + NativeBindState g_nativeBindState = NativeBindState::NotBound; + EGLDisplay g_nativeBoundDpy = EGL_NO_DISPLAY; + EGLSurface g_nativeBoundDraw = EGL_NO_SURFACE; + EGLSurface g_nativeBoundRead = EGL_NO_SURFACE; + EGLContext g_nativeBoundCtx = EGL_NO_CONTEXT; + + void NoteNativeContextGone() { g_nativeBindState = NativeBindState::NotBound; } + void NoteNativeContextFreshFromSurfaceCreation() { + g_nativeBindState = NativeBindState::FreshFromSurfaceCreation; + } + void NoteNativeBoundFor(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) { + g_nativeBindState = NativeBindState::BoundForTuple; + g_nativeBoundDpy = dpy; + g_nativeBoundDraw = draw; + g_nativeBoundRead = read; + g_nativeBoundCtx = ctx; + } + Bool NativeBindCanBeSkippedFor(EGLDisplay dpy, EGLSurface draw, EGLSurface read, EGLContext ctx) { + switch (g_nativeBindState) { + case NativeBindState::NotBound: return false; + case NativeBindState::FreshFromSurfaceCreation: return true; + case NativeBindState::BoundForTuple: + return g_nativeBoundDpy == dpy && g_nativeBoundDraw == draw && g_nativeBoundRead == read && + g_nativeBoundCtx == ctx; + } + return false; + } +#endif + void ClearGLErrors(const MG_External::GLESFunctionsTable& gl) { if (!gl.glGetError) return; while (gl.glGetError() != GL_NO_ERROR) {} @@ -812,6 +860,19 @@ namespace MobileGL::MG_Backend::DirectGLES { Int maxSamples = 0; const SizeT formatIndex = static_cast(logicalFormat); +#if MOBILEGL_BUILD_DISAGGREGATED + // C6 / ID-52: read the ROLE's own cache. Under split that is the server's private backend + // (ActiveBackendFormatCaps), not pActiveBackendObject, which holds the client's mirror. + const FormatCapabilityCache* activeCaps = ActiveBackendFormatCaps(); + if (activeCaps != nullptr && targetIndex < kFormatCapabilityTargetCount && + formatIndex < kFormatCapabilityFormatCount) { + // Descending, so the head is the largest count this device actually allocated. + const Vector& probedCounts = activeCaps->SampleCounts[targetIndex][formatIndex]; + if (!probedCounts.empty()) { + maxSamples = probedCounts.front(); + } + } +#else if (pActiveBackendObject && targetIndex < kFormatCapabilityTargetCount && formatIndex < kFormatCapabilityFormatCount) { // Descending, so the head is the largest count this device actually allocated. @@ -821,6 +882,7 @@ namespace MobileGL::MG_Backend::DirectGLES { maxSamples = probedCounts.front(); } } +#endif if (maxSamples <= 0) { maxSamples = GetGLESFormatMaxSamples(g_GLESCapabilities, logicalFormat, imageFormat); } @@ -829,6 +891,9 @@ namespace MobileGL::MG_Backend::DirectGLES { BackendObject_DirectGLES::~BackendObject_DirectGLES() { DestroyEGLContext(); +#if MOBILEGL_BUILD_DISAGGREGATED + NoteNativeContextGone(); +#endif } Bool BackendObject_DirectGLES::InitWindowSurface() { @@ -906,7 +971,20 @@ namespace MobileGL::MG_Backend::DirectGLES { ResetEGLRuntimeState(); } +#if MOBILEGL_BUILD_DISAGGREGATED + // ID-54 / ID-67: the surface's creation bound natively (InitWindowSurface -> MakeCurrent); + // the first virtual tuple adopts that bind. On failure nothing is known to be bound. The + // pull arm below is the original statement, byte for byte (G1). + const Bool created = BackendObject::CreateEGLWindowSurface(surface, handle); + if (created) { + NoteNativeContextFreshFromSurfaceCreation(); + } else { + NoteNativeContextGone(); + } + return created; +#else return BackendObject::CreateEGLWindowSurface(surface, handle); +#endif } Bool BackendObject_DirectGLES::CreateEGLPbufferSurface(EGLSurface surface, EGLint width, EGLint height) { @@ -925,7 +1003,19 @@ namespace MobileGL::MG_Backend::DirectGLES { ResetEGLRuntimeState(); } +#if MOBILEGL_BUILD_DISAGGREGATED + // ID-54 / ID-67: as for the window surface - InitPbufferSurface bound natively. The pull + // arm below is the original statement, byte for byte (G1). + const Bool created = BackendObject::CreateEGLPbufferSurface(surface, width, height); + if (created) { + NoteNativeContextFreshFromSurfaceCreation(); + } else { + NoteNativeContextGone(); + } + return created; +#else return BackendObject::CreateEGLPbufferSurface(surface, width, height); +#endif } Bool BackendObject_DirectGLES::InitPbufferSurface(EGLint width, EGLint height) { @@ -938,6 +1028,9 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!DirectGLES::ReleaseCurrent()) { return false; } +#if MOBILEGL_BUILD_DISAGGREGATED + NoteNativeContextGone(); +#endif return BackendObject::MakeEGLCurrent(dpy, draw, read, ctx); } @@ -958,12 +1051,50 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } +#if MOBILEGL_BUILD_DISAGGREGATED + // ID-54 / C7, the native half of "bind once per tuple" (v1, under ID-52/ID-59's grant for + // this file; #if-guarded so the pull build is byte-identical). Under an active transport + // this runs on the apply thread, which is the ONLY thread that ever binds the server's + // context, and the surface it is asked for was made natively current on this very thread + // by its own creation (InitPbufferSurface / InitWindowSurface -> DirectGLES::MakeCurrent). + // A second native eglMakeCurrent for the same surface is then a repeat: it rewrites the + // owner with the same thread, re-registers the same op table and invalidates seven caches + // that describe a context that did not change - the per-client-make-current storm the + // v2 review measured as "2 native binds per process, unchanged". So when the requested + // draw surface IS the active one and EGL itself says this thread holds the context + // (IsBackendContextCurrentOnThisThread re-verifies against eglGetCurrentContext), the + // native call is skipped and only the base class's bookkeeping below runs - which is + // still required: it is what InitCapabilities and SwapEGLBuffers' current-thread record + // hang off. Monolith transport in this build, and the pull build, bind exactly as before. + // + // AND ONLY FOR THE SAME VIRTUAL TUPLE (ID-67). The skip is keyed on NativeBindCanBeSkippedFor: + // the surface's own creation bind is adopted by the FIRST tuple, an identical tuple skips, + // and a DIFFERENT tuple - a second MobileGL context onto the same surface - runs the native + // call again even though the driver's triple is the same, because MakeCurrent's seven + // invalidations describe the frontend context that is changing. Red once, two ways: + // make this arm unconditional (ServerLoopTest's C7 control reads 2 native binds at the EGL + // function table instead of 1), or make NativeBindCanBeSkippedFor answer true for any tuple + // (the ID-67 control's different tuple stays at 1 native bind where 2 are required). + const Bool nativelyCurrentAlready = MG_Config::Transport != MG_Config::TransportMode::Monolith && + m_eglSurfaceInitialized && m_eglSurface == draw && + DirectGLES::IsBackendContextCurrentOnThisThread() && + NativeBindCanBeSkippedFor(dpy, draw, read, ctx); + if (!nativelyCurrentAlready && !DirectGLES::MakeCurrent()) { + NoteNativeContextGone(); + return false; + } + NoteNativeBoundFor(dpy, draw, read, ctx); +#else if (!DirectGLES::MakeCurrent()) { return false; } +#endif if (!BackendObject::MakeEGLCurrent(dpy, draw, read, ctx)) { (void)DirectGLES::ReleaseCurrent(); +#if MOBILEGL_BUILD_DISAGGREGATED + NoteNativeContextGone(); +#endif return false; } return true; @@ -981,12 +1112,18 @@ namespace MobileGL::MG_Backend::DirectGLES { void BackendObject_DirectGLES::ReleaseEGLResources() { const std::lock_guard lock(m_eglStateMutex); DestroyEGLContext(); +#if MOBILEGL_BUILD_DISAGGREGATED + NoteNativeContextGone(); +#endif BackendObject::ReleaseEGLResources(); } void BackendObject_DirectGLES::OnEGLSurfaceReleased(EGLSurface surface) { (void)surface; DestroyEGLContext(); +#if MOBILEGL_BUILD_DISAGGREGATED + NoteNativeContextGone(); +#endif } const RendererInfo& BackendObject_DirectGLES::GetRendererInfo() const { @@ -1255,8 +1392,6 @@ namespace MobileGL::MG_Backend::DirectGLES { funcsTable.GL.MemoryBarrierByRegion = MemoryBarrierByRegion; funcsTable.GL.BindImageTexture = BindImageTexture; funcsTable.GL.GetIntegeri_v = GetIntegeri_v; - funcsTable.GL.GetInteger64i_v = GetInteger64i_v; - funcsTable.GL.GetProgramiv = GetProgramiv; funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding; funcsTable.GL.Clear = Clear; funcsTable.GL.ClearBufferfi = ClearBufferfi; @@ -1417,6 +1552,13 @@ namespace MobileGL::MG_Backend::DirectGLES { clampStageStorageBlocks(m_GLESCapabilities.MaxFragmentShaderStorageBlocks); m_dynamicParameters.MaxComputeUniformBlocks = m_GLESCapabilities.MaxComputeUniformBlocks; m_dynamicParameters.MaxComputeWorkGroupInvocations = m_GLESCapabilities.MaxComputeWorkGroupInvocations; + // The six per-axis compute limits: the driver's raw glGetIntegeri_v answers, the same + // numbers GLFunctionsTable::GetIntegeri_v forwards live. Carried here so that MGPCaps has + // them once the table entry retires (plan B section 4.4.1); GL_Getter floors them. + for (SizeT axis = 0; axis < 3; ++axis) { + m_dynamicParameters.MaxComputeWorkGroupCount[axis] = m_GLESCapabilities.MaxComputeWorkGroupCount[axis]; + m_dynamicParameters.MaxComputeWorkGroupSize[axis] = m_GLESCapabilities.MaxComputeWorkGroupSize[axis]; + } // (MaxShaderStorageBufferBindings is assigned above, before the per-stage clamp reads it.) // This is the number glGetIntegerv(GL_MAX_TEXTURE_BUFFER_SIZE) hands the application, and // on a host without buffer textures it is knowingly a floor MobileGL cannot honour rather diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp index 726cbf8d2..f16299903 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.cpp @@ -16,6 +16,22 @@ #include #include #include +#include +#if MOBILEGL_PIPE_PUSH +// P3a: the applier's vertex-input records the re-keyed draw-buffer memo is validated against. +#include +// P5c ev: the surface-changed event's producer callback, installed by the server session. +#include +// P5e (pa): MGPipeShaderCsoRecord::Archive is a SharedPtr and PipeApply.h +// deliberately only forward-declares the type (its own note at :46 says to come here for it). +// The draw path's attribute-values sync reads Archive->Link, so this TU needs it complete - +// Managers.cpp already includes it for ProgramArchiveSource, which is the same reason. +#include +#endif +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (tx): §1's server-side per-level extent derivation, for GenerateMipmap's shape reads. +#include +#endif #include #include #include @@ -28,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -55,9 +72,14 @@ namespace MobileGL::MG_Backend::DirectGLES { static SharedPtr g_rawDepthFetchSamplerState; static SharedPtr g_rawDepthFetchSamplerBackend; +#if MOBILEGL_PIPE_LEGACY_MEMOS // Two objects are the same binding iff they share a control block. Raw addresses lie // (a freed object's heap slot is reused), but a held weak_ptr pins the control block, // so no later object can ever owner-equal a snapshot of its predecessor. + // + // This is the pre-handle identity mechanism, and it is compiled only for the legacy arm. + // On the {slot, gen} arm nothing needs it: a handle already cannot be reproduced by a + // recycled address, so there is no snapshot to owner-compare. template static Bool OwnerEquals(const WeakPtr& snapshot, const SharedPtr& current) { return !snapshot.owner_before(current) && !current.owner_before(snapshot); @@ -128,6 +150,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // FBOs; 64 slots is plenty. static TwinLookupMemo g_fboTwinLookupMemo; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS // Cached addresses of the frontend's framebuffer binding slots. The frontend // getter linear-scans its slot array per call and the draw path asks for these @@ -139,19 +162,112 @@ namespace MobileGL::MG_Backend::DirectGLES { // addresses again - the cached pointers cannot go stale. Invalidation is // exactly the pointer compare below. using FbBindingSlot = - std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; - static const MG_State::GLState::GLContext* g_fbSlotCacheContext = nullptr; + std::remove_reference_tGetFramebufferBindingSlot(FramebufferTarget::Draw))>; +#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS + static const void* g_fbSlotCacheContext = nullptr; static Array g_fbSlotCache = {}; - static inline FbBindingSlot& GetFramebufferBindingSlotFast(FramebufferTarget target) { - MG_State::GLState::GLContext* ctx = MG_State::pGLContext.get(); +#endif + // P2 step e4. On the {slot, gen} arm this is an ORDINARY read of pushed state and the cache + // above is not consulted, which closes the P1 accessor bypass: the cached raw pointer ran + // the checked accessor once per context change and then handed out the pointee forever, so + // at all five call sites the per-verb poison stamp (MGP_INPUT_CHECK) and the verify + // read-hook (MGP_INPUT_VERIFY_READ) were skipped. A verb that legitimately never fills + // GetFramebufferBindingSlot could not be caught here, and a verify build compared the field + // only where the slow accessor was used. + // + // The cache stays on the LEGACY arm, gated on the same subsystem bit as the rest of this + // slice, so that MOBILEGL_PIPE_PUSH=0 keeps being the faithful all-subsystems-pull control + // ConfigLoader.cpp documents - "reproduces P1's behaviour exactly" has to include this + // path, or the integrator's A/B measures e4 on both arms and attributes it to neither. In + // the pull build MGB_CTX is the live GLContext, there is no poison to bypass and the + // frontend getter still linear-scans, so the cache is exactly the code it was. + static inline FbBindingSlot& GetFramebufferBindingSlotChecked(FramebufferTarget target) { +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + return MGB_CTX->GetFramebufferBindingSlot(target); + } +#endif +#if !MOBILEGL_PIPE_PUSH || MOBILEGL_PIPE_LEGACY_MEMOS + const void* ctx = MGB_CTX_IDENTITY; if (ctx != g_fbSlotCacheContext) { + auto& live = *MGB_CTX; for (SizeT i = 0; i < g_fbSlotCache.size(); ++i) { - g_fbSlotCache[i] = &ctx->GetFramebufferBindingSlot(static_cast(i)); + g_fbSlotCache[i] = &live.GetFramebufferBindingSlot(static_cast(i)); } g_fbSlotCacheContext = ctx; } return *g_fbSlotCache[SizeT(target)]; +#else + // No legacy arm compiled: EsprytSlotTablesEnabled() is unconditionally true above. + return MGB_CTX->GetFramebufferBindingSlot(target); +#endif + } + +#if MOBILEGL_PIPE_PUSH + // A4, TAKEN AT THE VERIFICATION ROUND, AND THE FOUR TEMPORARY LATCHES ARE GONE WITH IT. + // This package landed BEFORE D and so carried its own bit tests, plus a copy of D-K2's + // dependency directions, so that a half-running mask was refused in the meantime too. + // Package D has landed, so every consult below calls D's PUBLIC WRAPPERS instead - + // FramebufferSubsystemEnabled() / TextureResourceSubsystemEnabled() / + // SamplerSubsystemEnabled() / ProgramSubsystemEnabled() (Managers.h, beside the four + // ResolveSubsystemArm() resolvers; the WRAPPER holds the `static const` latch, so + // the resolver is never the thing to call) - which additionally LOG each dependency + // refusal naming both bits and reach Fatal{PipeLegacyMemosDisabled} under + // MOBILEGL_PIPE_LEGACY_MEMOS=0, neither of which a copy here could do. + // + // NOTHING HERE RESTATES A DEPENDENCY DIRECTION any more - D-K2's fourth row (bit 10 + // requires bit 11, ID-15) included. It is in ResolveTextureResourceSubsystemArm, which is + // its owner, and two writers of one diagnostic is how the two drift. + + // THE ONE PLACE THIS FILE ASKS THE APPLIER "which framebuffer record describes this + // binding". Six reads used to spell `MGPipeApplier().DrawFramebuffer` / `.ReadFramebuffer` + // inline; they go through here instead, for exactly one reason. + // + // ID-19 turns the applier's framebuffer state into a PER-OBJECT table keyed by the + // framebuffer handle, with `BoundFramebuffer[Draw|Read]` holding the bound handles, so that + // the DSA entry points (BlitNamedFramebuffer, the four ClearNamedFramebuffer*) can be + // handed a record for a framebuffer that is bound to neither binding. Wire v3 keeps + // `DrawFramebuffer` / `ReadFramebuffer` as ACCESSORS OF THOSE NAMES resolving through the + // bound handle, precisely so this package keeps its shape. If they land as member functions + // rather than as members, this one function grows a pair of parentheses and nothing else in + // this file moves; if the accessor can answer "no record for the bound handle", that answer + // arrives here as a null Fbo, which is already what every caller treats as a decline. + // + // WIRE v3 LANDED BOTH HALVES OF THAT PREDICTION: they are member FUNCTIONS and they return + // a NULLABLE POINTER, so the four call sites gained a null check each and nothing else in + // this file moved. Null is a real answer with exactly three causes (PipeApply.h): nothing + // is bound to that binding, no record has been written at the bound handle's slot, or the + // slot's generation has moved on under the handle - the third counted AND logged by the + // applier itself in StaleFramebufferRecordLookups. All three already arrived here as a + // null Fbo and were already a decline; the callers test the pointer instead. + static const MG_Pipe::MGPFramebufferState* BoundFramebufferRecord(FramebufferTarget target) { + const auto& st = MG_Pipe::MGPipeApplier(); + return target == FramebufferTarget::Draw ? st.DrawFramebuffer() : st.ReadFramebuffer(); + } + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, CONTRACT-P5E.md §5.4 and §0's rule F). THE DECLINE BECOMES THE DETECTOR. + // + // Every framebuffer decline in this file used to fall back to the pre-handle arm, which + // reads GetFramebufferBindingSlot - a BARRIER_PULLED row (FieldOwnership.def). Under an + // active transport that fallback is not a safe default any more: the value it would read + // belongs to a client that is no longer parked behind this apply, so the honest answer is + // the same one the accessor itself would give one line later, raised HERE, where the + // reason is still in scope. Naming the verb is what makes the strict lane's marker table + // ("@") point at the site instead of at the accessor. + // + // ONLY UNDER A TRANSPORT AND ONLY WITH THE FAMILY BIT SET (ruling 1 / §5.8): the + // push-monolith build keeps its frontend arms token for token, and a mask that never + // armed framebuffers has no record to decline from. + static Bool FramebufferRecordArmIsMandatory() { + return MG_Config::Transport != MG_Config::TransportMode::Monolith && FramebufferSubsystemEnabled(); + } + [[noreturn]] static void RefuseFramebufferBindingSlotRead() { + MG_Pipe::MGPipeInputPoisonFatalForVerb(MG_Pipe::MGPipeInputField::GetFramebufferBindingSlot, + MG_Pipe::gPipeInputs.CurrentVerb()); } +#endif // MOBILEGL_BUILD_DISAGGREGATED +#endif // MOBILEGL_PIPE_PUSH static Bool IsDualSourceBlendFactor(BlendFactor v) { switch (v) { @@ -165,6 +281,23 @@ namespace MobileGL::MG_Backend::DirectGLES { } } + // P4a (D-F3, [correction]). THE RAW-DEPTH-FETCH SUBSTITUTION STAYS ON THE SERVER, and it + // keeps constructing a frontend SamplerObject inside MG_Backend to do it. That is + // deliberate and it is not this phase's to change: ARCHITECTURE.md assigns the two + // backend-specific post-processings of the resolved sampler set - this one and Magma's + // feedback-loop detection - to the server, acting ON the already-resolved set, and the + // ROADMAP puts "raw-depth-fetch sampler 原生化" in P3b/P4b beside Magma's placeholder + // textures. What P4a changes is only what the substitution READS: the resolved view's + // format answers IsDepthFormatInternalFormat and the sampler CSO record's parameters + // answer compareMode / minFilter / mipmapMode / magFilter. + // + // RECORDED HERE ON PURPOSE: this pair of file-static SharedPtrs is the largest surviving + // MG_State-type usage anywhere under MG_Backend/DirectGLES, and its owner is P3b/P4b - so + // the include-graph gate at P13 meets a known item rather than a surprise. The purity + // gates are unaffected either way: they grep MG_Backend for the pull arm's live-GLContext + // pointer token (G13 - deliberately not spelled here, because that grep is a BARE TOKEN + // grep and a comment naming it is a hit) and for an MG_State type inside + // MGPipeResourceOps, and this is neither. SamplerImpl::BackendSamplerObject* GetRawDepthFetchSampler() { if (!g_rawDepthFetchSamplerState) { g_rawDepthFetchSamplerState = MakeShared(0); @@ -179,13 +312,16 @@ namespace MobileGL::MG_Backend::DirectGLES { return g_rawDepthFetchSamplerBackend.get(); } - Bool NeedsRawDepthFetchSampler(const SharedPtr& samplerObject, + // P5e (tx2), CONTRACT-P5E §5.3: the substitution's decision, over the fifteen sampler VALUES + // rather than over the object that happens to hold them. ARCHITECTURE.md §5.5 keeps the + // substitution itself on the server; this is only where its inputs come from, and the CSO + // record carries all three (MGPipeValueTypes.h). The SharedPtr overload below is the same + // three lines with one dereference in front, so the two arms cannot part. + Bool NeedsRawDepthFetchSampler(const SamplerParameters& samplerParams, TextureInternalFormat textureFormat) { - if (!MG_Util::IsDepthFormatInternalFormat(textureFormat) || !samplerObject) { + if (!MG_Util::IsDepthFormatInternalFormat(textureFormat)) { return false; } - - const auto& samplerParams = samplerObject->GetAllSamplerParameters(); if (samplerParams.compareMode != SamplerCompareMode::None) { return false; } @@ -195,6 +331,14 @@ namespace MobileGL::MG_Backend::DirectGLES { samplerParams.magFilter != SamplerFilterMode::Nearest; } + Bool NeedsRawDepthFetchSampler(const SharedPtr& samplerObject, + TextureInternalFormat textureFormat) { + if (!samplerObject) { + return false; + } + return NeedsRawDepthFetchSampler(samplerObject->GetAllSamplerParameters(), textureFormat); + } + // Frontend texture target a GLSL sampler uniform samples from. Only used to find // which of a unit's bindings carries the GL_TEXTURE_LOD_BIAS the shader needs; // targets with no mip chain map to Unknown so the lookup falls back to no bias. @@ -257,7 +401,43 @@ namespace MobileGL::MG_Backend::DirectGLES { } const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) { - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): under an active transport the command buffer resolves + // from the handle the verb record carried, and its CPU bytes are the SERVER's staged + // shadow (rule E) - the client's GL_DRAW_INDIRECT_BUFFER binding slot, the frontend + // object's MappedData()/GetSize() (B3) and the client slot allocator (T2) are never + // read. Coverage is asserted: commands read from bytes no record staged would be + // zero-filled, which is a missing record, not a valid command stream. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeApplier().VerbIndirectBuffer; + if (!MG_Pipe::MGPipeHandleIsNull(handle)) { + auto* resource = BufferImpl::FindBufferResourceForHandle(handle); + const Uint8* const base = resource ? resource->hostBytes : nullptr; + const SizeT commandOffset = reinterpret_cast(indirect); + if (base == nullptr) { + MGLOG_E_ONCE("%s skipped: the verb's indirect buffer {%u, %u} has no staged " + "shadow on this side (GPU-written indirect commands are P8's)", + label, handle.Slot, handle.Gen); + return nullptr; + } + if (commandOffset + requiredBytes > BufferImpl::ResourceWidthForHandle(handle)) { + MGLOG_E_ONCE("%s skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range", label); + return nullptr; + } + BufferImpl::RequireStagedCoverage(*resource, base, commandOffset, + commandOffset + requiredBytes, "indirect_command_bytes"); + return base + commandOffset; } + // No buffer was bound: the frontend already raised the GL error; keep the old + // fall-through shape (a null indirect pointer is the same skip it was). + if (!indirect) { + MGLOG_E_ONCE("%s skipped: indirect pointer is null", label); + return nullptr; + } + MGLOG_E_ONCE("%s skipped: the verb carried no indirect buffer handle", label); + return nullptr; + } +#endif + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { drawBuffer->SyncPersistentMappedRange(); const SizeT commandOffset = reinterpret_cast(indirect); @@ -347,13 +527,201 @@ namespace MobileGL::MG_Backend::DirectGLES { // TODO: deletion for deleted objects namespace BufferImpl { + // P5e (mv): THE ARM SELECTOR of this family - VertexInputReadsRecords() - moved to + // Managers.h, unchanged. MultiDraw.cpp is a second translation unit on the same draw + // path and has to select the same arm; a copy of the conjunction there would be two + // selectors for one family. Its rationale travelled with it. + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (vi): "does this draw fetch any attribute out of the application's own memory", + // answered FROM THE RECORD instead of from the frontend VAO. The emitter publishes + // Res == kMGPipeNullHandle for a client-memory array (VertexInputEmit.h says so in as + // many words: "A client-memory array is Res == kMGPipeNullHandle, and that is not a + // hole"), and the window is truncated at the highest ENABLED attribute, so a null Res + // inside [Start, Start+Count) is exactly the client-sourced case and nothing else. + // + // BOTH VIEWS ARE NEEDED, and taking only the buffer one would be wrong in the common + // direction: the window covers [0, highest enabled + 1), so a VAO whose attribute 0 is + // DISABLED and whose attribute 1 feeds from a VBO publishes entry[0].Res == null too. + // A null Res is "client-sourced" only for an attribute the CONFIGURATION has enabled, + // which is what the vertex-elements record answers. Reading one view and not the other + // would send every such ordinary draw back to the frontend VAO and keep the row this + // package is retiring alive for no reason. + // + // A prefix scan rather than a memo: Count is 1-3 on the measured workload, not 32, and + // this replaces a cross-TU accessor call plus a SharedPtr dereference per DrawArrays. + Bool AnyClientSideVertexArrayInRecord() { + const auto& st = MG_Pipe::MGPipeApplier(); + if (st.VertexBufferCount == 0) return false; + const MG_Pipe::MGPipeHandle elements = st.BoundVertexElements; + if (MG_Pipe::MGPipeHandleIsNull(elements) || elements.Slot >= st.VertexElementsCsos.size()) { + return false; + } + const auto& record = st.VertexElementsCsos[elements.Slot]; + if (!record.Live || record.Gen != elements.Gen) return false; + + const Uint32 begin = st.VertexBufferStart; + const Uint32 end = begin + st.VertexBufferCount; + for (Uint32 i = begin; i < end && i < MG_Pipe::kMGPipeMaxVertexAttribs; ++i) { + if (!record.Attributes[i].Enabled) continue; + if (MG_Pipe::MGPipeHandleIsNull(st.VertexBuffers[i].Res)) return true; + } + return false; + } + + // See Managers.h for why these two are named functions over the applier state and + // nothing else. + Uint ResolveDrawVertexBuffersFromRecord(const MG_Pipe::MGPipeApplierState& st, + DrawVertexBufferRequest* out, Uint capacity) { + Uint count = 0; + const Uint32 begin = st.VertexBufferStart; + const Uint32 end = begin + st.VertexBufferCount; + for (Uint32 i = begin; i < end && i < MG_Pipe::kMGPipeMaxVertexAttribs; ++i) { + const MG_Pipe::MGPVertexBuffer& binding = st.VertexBuffers[i]; + if (MG_Pipe::MGPipeHandleIsNull(binding.Res)) continue; + + // A {slot, gen} compare, not a raw address compare: an address a successor + // object can reproduce is the identity hazard this phase exists to remove. + Bool alreadySeen = false; + for (Uint j = 0; j < count; ++j) { + if (out[j].Res == binding.Res) { + alreadySeen = true; + break; + } + } + if (alreadySeen) continue; + if (count >= capacity) { + MGLOG_E_ONCE("MGPipe: the vertex-buffer window named more distinct buffers than " + "a draw can hold (%u); the tail is dropped", capacity); + break; + } + out[count].Res = binding.Res; + out[count].BindingIndex = binding.BindingIndex; + ++count; + } + return count; + } + + DrawIndexBufferRequest ResolveDrawIndexBufferFromRecord(const MG_Pipe::MGPipeApplierState& st) { + DrawIndexBufferRequest request; + request.Res = st.IndexBuffer.Res; + request.Serial = st.IndexBufferSerial; + return request; + } +#endif // MOBILEGL_BUILD_DISAGGREGATED - vi's record arm ends here; sb's opens below + +#if MOBILEGL_BUILD_DISAGGREGATED + // ---- P5e (sb, MG_Remote/CONTRACT-P5E.md §5.6): THE INDEXED BINDING POINTS BY RECORD -- + // + // WHICH ARM THIS SERVER TAKES, resolved once. Two conjuncts and both are the phase's + // standard shape (§5.8): a live TRANSPORT - the push build under Transport=monolith + // keeps its frontend arms token for token, which is what the verify comparator needs + // and what makes RUN_AHEAD=0 a pure wait-rule A/B on identical server code - AND the + // family bit, so an operator can put the whole binding-point family back on the + // frontend walk with one cleared bit. + // + // BIT 13 REQUIRES BIT 7, refused here rather than half-run, in + // ResolveVertexInputSubsystemArm's exact shape and for its exact reason: every + // MGPBufferRange::Res is resolved through the resource slot table and only bit 7 puts + // twins there, so the pair would produce a walk in which every point logged once and + // `continue`d WITHOUT unbinding - every draw then reading through whatever the driver + // last had at that point. + Bool ResolveBufferBindingSubsystemArm() { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return false; + const Bool bitSet = + (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemBufferBindings) != 0; + const Bool resourcesBitSet = + (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemResources) != 0; + if (bitSet && !resourcesBitSet) { + MGLOG_E("MGPipe: kMGPipeSubsystemBufferBindings (bit 13) is set but " + "kMGPipeSubsystemResources (bit 7) is clear; every MGPBufferRange::Res " + "resolves through the resource slot table, which only bit 7 populates - " + "REFUSING bit 13 and running the legacy binding-point walk. Set bit 7 as " + "well, or clear both"); + return false; + } + MGLOG_D("MGPipe: Espryt binding-point family runs the %s arm", + bitSet ? "record" : "legacy"); + return bitSet; + } + + // FILE-LOCAL AND INLINE-MEMOISED, for EsprytSlotTablesEnabled's reason: this is + // consulted on the per-draw path (twice in SyncNeccessaryBuffers, once per UBO in the + // program rebind), and out of line it would be a call through the PLT per consult. + Bool BindingPointsComeFromRecords() { + static const Bool enabled = ResolveBufferBindingSubsystemArm(); + return enabled; + } + + // The record arm of SyncBufferBindingPoints. Same shape, same order, same branches; the + // four frontend reads become the applier's window and a handle per entry. + // + // WHAT THE WINDOW IS: ShaderBufferCount[class] starting at ShaderBufferStart[class], + // which is the client's touched high-water mark clamped to the 84 the wire carries + // (ruling 10). It is NOT clamped on the client to the device's ceiling, so the ES clamp + // below stays exactly where it was - a client reading GL_MAX_UNIFORM_BUFFER_BINDINGS + // would be answering a driver question from the wrong side of the wire. + // + // WHOLE-VS-RANGE IS `Offset == 0 && Size == kMGPipeWholeBuffer` and obj->GetSize() + // disappears from this function: a base binding does not freeze an extent, the client + // deliberately does not resolve one, and the re-resolution happens HERE against the + // server's own storage - which is what makes a glBufferData issued between the emission + // and this apply bind the NEW extent, as GL does. + void SyncBufferBindingPointsByRecord(Uint32 shaderBufferClass, GLenum glTarget) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + const MG_Pipe::MGPipeApplierState& st = MG_Pipe::MGPipeApplier(); + const Uint32 start = st.ShaderBufferStart[shaderBufferClass]; + SizeT end = static_cast(start) + st.ShaderBufferCount[shaderBufferClass]; + // ...and never past what the ES driver itself can hold, exactly as the frontend + // walk does and for the same reason recorded there. + if (glTarget == GL_UNIFORM_BUFFER && g_GLESCapabilities.MaxUniformBufferBindings > 0) { + end = std::min(end, static_cast(g_GLESCapabilities.MaxUniformBufferBindings)); + } + for (SizeT i = start; i < end; ++i) { + const MG_Pipe::MGPBufferRange& entry = + st.BoundShaderBuffers[shaderBufferClass][i]; + if (MG_Pipe::MGPipeHandleIsNull(entry.Res)) { + BindBufferBaseCached(glTarget, static_cast(i), 0); + continue; + } + auto* backendResource = EnsureBufferResourceForHandle(nullptr, entry.Res); + if (!backendResource || backendResource->id == 0) { + MGLOG_E_ONCE("No backend buffer found for %s binding point %zu (handle {%u, %u}).", + MG_Util::ConvertGLEnumToString(glTarget).c_str(), i, entry.Res.Slot, + entry.Res.Gen); + continue; + } + const auto backendBufferId = backendResource->id; + if (entry.Offset == 0 && entry.Size == MG_Pipe::kMGPipeWholeBuffer) { + BindBufferBaseCached(glTarget, static_cast(i), backendBufferId); + } else { + // CLAMPED AGAINST THE SERVER's OWN STORAGE, which is what the frontend walk + // clamped against obj->GetSize() for: a range that outruns the storage is a + // GL_INVALID_VALUE the driver would raise on a bind the application already + // made legally against a buffer that has since shrunk. + const SizeT storage = backendResource->storageSize; + const SizeT rangeStart = std::min(static_cast(entry.Offset), storage); + const SizeT rangeEnd = + std::min(static_cast(entry.Offset + entry.Size), storage); + BindBufferRangeCached(glTarget, static_cast(i), backendBufferId, + static_cast(rangeStart), + static_cast(rangeEnd - rangeStart)); + } + } + } +#else + inline Bool BindingPointsComeFromRecords() { return false; } +#endif + void SyncBufferBindingPoints(BufferTarget target, GLenum glTarget) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif // Only sync up to the high-water mark of app-touched points; the fixed array is 84 // deep but apps bind a handful, so the never-touched tail is already at GL default 0. - auto bindingPointCnt = MG_State::pGLContext->GetTouchedBufferBindingPointCount(target); + auto bindingPointCnt = MGB_CTX->GetTouchedBufferBindingPointCount(target); // ...and never past what the ES driver itself can hold. MobileGL advertises the GL 4.5 // minimum of 84 uniform binding points while the ES 3.2 minimum is 72, so a frontend // index in that gap would reach glBindBufferBase as GL_INVALID_VALUE. Nothing is lost @@ -366,7 +734,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(g_GLESCapabilities.MaxUniformBufferBindings)); } for (SizeT i = 0; i < bindingPointCnt; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(target, i); + auto& point = MGB_CTX->GetBufferBindingPoint(target, i); auto& obj = point.GetBoundObject(); if (!obj) { BindBufferBaseCached(glTarget, static_cast(i), 0); @@ -425,7 +793,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT pointCount = std::min( bufferCount, MG_State::GLState::GLContext::MAX_TRANSFORM_FEEDBACK_BUFFERS); for (SizeT i = 0; i < pointCount; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, i); + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::TransformFeedback, i); const auto& obj = point.GetBoundObject(); // A stride-0 slot (two consecutive gl_NextBuffer entries) captures nothing and // needs no binding; anything else with no buffer never got past the frontend. @@ -457,12 +825,38 @@ namespace MobileGL::MG_Backend::DirectGLES { // the frontend's CPU shadow. Flagging them makes the next MapBuffer/GetBufferSubData // pull the real contents back (BufferObject::SyncGpuWrites). void MarkShaderStorageBuffersGpuWritten() { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (sb, CONTRACT-P5E.md §5.6): DELETED UNDER A TRANSPORT, and this is the one + // site of the three that cannot merely be re-expressed by handle. The walk is over + // CLIENT memory - the frontend's binding-point array and the BufferObject it names - + // and the mark it makes reaches into a client object, so under run-ahead it would be + // reading and writing memory the client has already moved on from. Rule F. + // + // NOTHING IS LOST, and it is verified rather than asserted: the CLIENT already owns + // this exact set. MarkShaderStorageBindings (MG_Remote/Client/GpuWritePending.cpp) + // walks the same touched high-water mark over the same binding points and calls + // MarkGpuWritten on the same objects, from MarkGpuWritesForDraw in BeforeDrawVerb - + // i.e. BEFORE the draw crosses, on the GL thread, where the objects live. Under a + // transport this backend walk was a DUPLICATE of it (scout S5 §2), and the + // per-producer tally GpuWritePending.h keeps is what says so out loud. + // + // The applier's ShaderBufferWritableMask survives as the server's record of WHICH + // points a shader may write through, which is what P9's narrowing channel will name. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) return; +#endif const SizeT bindingPointCnt = - MG_State::pGLContext->GetTouchedBufferBindingPointCount(BufferTarget::ShaderStorage); + MGB_CTX->GetTouchedBufferBindingPointCount(BufferTarget::ShaderStorage); for (SizeT i = 0; i < bindingPointCnt; ++i) { const auto& obj = - MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, i).GetBoundObject(); + MGB_CTX->GetBufferBindingPoint(BufferTarget::ShaderStorage, i).GetBoundObject(); +#if MOBILEGL_PIPE_PUSH + // P3a (D-D): announced on the reverse channel on the handle arm, poked into + // the object on the legacy one. MarkBufferGpuWritten is the one place that + // decides, so the three announcement sites stay one line each. + MarkBufferGpuWritten(obj); +#else if (obj) obj->MarkGpuWritten(); +#endif } } @@ -470,14 +864,75 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - const SizeT pointCount = MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::AtomicCounter); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (sb, §5.6): THE RECORD ARM. `glBindings` stays the backend program's own list + // - it is server-owned since the link and names which GL counter bindings THIS + // program declares - and only the points themselves move sides. The bound is the + // applier's window instead of GetBufferBindingPointCount's sticky forward, which is + // the row this site was the Espryt reader of. + if (BindingPointsComeFromRecords()) { + const MG_Pipe::MGPipeApplierState& st = MG_Pipe::MGPipeApplier(); + const Uint32 windowStart = st.ShaderBufferStart[MG_Pipe::kMGPipeShaderBufferClassAtomicCounter]; + const SizeT windowEnd = static_cast(windowStart) + + st.ShaderBufferCount[MG_Pipe::kMGPipeShaderBufferClassAtomicCounter]; + for (const Int glBinding : glBindings) { + if (glBinding < 0) continue; + const Int esslBinding = esslBindingTop - glBinding; + // Already diagnosed once when the block was transpiled; nothing was bound to + // it there either, so there is nothing to unbind here. + if (esslBinding < 0) continue; + // OUTSIDE THE WINDOW IS "NOTHING BOUND", which is what the frontend array's + // default says too - so it takes the unbind arm rather than being skipped. + // Skipping would leave whatever the driver last had at that ESSL point, + // which is the hole the frontend walk's `continue` never had because its + // bound was the whole 84-point array. + const MG_Pipe::MGPBufferRange* entry = nullptr; + if (static_cast(glBinding) >= windowStart && + static_cast(glBinding) < windowEnd) { + entry = &st.BoundShaderBuffers[MG_Pipe::kMGPipeShaderBufferClassAtomicCounter] + [static_cast(glBinding)]; + } + if (entry == nullptr || MG_Pipe::MGPipeHandleIsNull(entry->Res)) { + BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, static_cast(esslBinding), 0); + continue; + } + auto* backendResource = EnsureBufferResourceForHandle(nullptr, entry->Res); + if (!backendResource || backendResource->id == 0) { + MGLOG_E_ONCE("No backend buffer found for atomic counter binding point %d " + "(handle {%u, %u}).", + glBinding, entry->Res.Slot, entry->Res.Gen); + continue; + } + if (entry->Offset == 0 && entry->Size == MG_Pipe::kMGPipeWholeBuffer) { + BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, static_cast(esslBinding), + backendResource->id); + } else { + const SizeT storage = backendResource->storageSize; + const SizeT rangeStart = std::min(static_cast(entry->Offset), storage); + const SizeT rangeEnd = + std::min(static_cast(entry->Offset + entry->Size), storage); + BindBufferRangeCached(GL_SHADER_STORAGE_BUFFER, static_cast(esslBinding), + backendResource->id, static_cast(rangeStart), + static_cast(rangeEnd - rangeStart)); + } + // AND NO MarkBufferGpuWritten HERE. It was the second of the three backend + // GPU-write sites and it reached into a client object; the client's + // MarkAtomicCounterBindings (GpuWritePending.cpp) already marks the same set + // from MarkGpuWritesForDraw, WIDER on purpose - it marks every touched + // counter point rather than only the ones this program declares, which is + // the safe direction for an over-approximate set. + } + return; + } +#endif + const SizeT pointCount = MGB_CTX->GetBufferBindingPointCount(BufferTarget::AtomicCounter); for (const Int glBinding : glBindings) { if (glBinding < 0 || static_cast(glBinding) >= pointCount) continue; const Int esslBinding = esslBindingTop - glBinding; // Already diagnosed once when the block was transpiled; nothing was bound to it // there either, so there is nothing to unbind here. if (esslBinding < 0) continue; - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::AtomicCounter, + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::AtomicCounter, static_cast(glBinding)); auto& obj = point.GetBoundObject(); if (!obj) { @@ -506,7 +961,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // conformance case reads the result back with glMapBufferRange or // glGetBufferSubData - which serve the frontend's CPU shadow until the buffer is // flagged (BufferObject::SyncGpuWrites), exactly as for a storage buffer. +#if MOBILEGL_PIPE_PUSH + MarkBufferGpuWritten(obj); +#else obj->MarkGpuWritten(); +#endif } } @@ -514,7 +973,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& bufferObject = MG_State::pGLContext->GetBufferBindingSlot(target).GetBoundObject(); + auto& bufferObject = MGB_CTX->GetBufferBindingSlot(target).GetBoundObject(); if (!bufferObject) { g_GLESFuncs.glBindBuffer(glTarget, 0); return; @@ -528,6 +987,214 @@ namespace MobileGL::MG_Backend::DirectGLES { BindBufferId(glTarget, backendResource->id); } +#if MOBILEGL_PIPE_PUSH + // The handle arm of the resolved-draw-buffers memo (D-G4). Two substitutions and + // nothing else: + // + // validity the frontend VAO's wrapping configuration version is replaced by the + // bound vertex-elements CSO ({slot, gen} AND its server-owned content + // serial) plus the vertex-buffer set's own serial - so an emission the + // suppressor let through is what re-opens the memo, not a counter the + // backend reads out of the frontend; + // identity an entry names its resource by handle and the clean probe compares that, + // instead of a raw frontend address that a successor object can reproduce. + // + // The WALK itself still reads the frontend VAO's attributes, and deliberately: the + // buffers a draw needs ensured is a pull site P3a does not migrate (dirty bits 15-17 + // are P4b's and the resolution move is P8's), and EnsureBufferResource still owes + // BufferObject::SyncPersistentMappedRange one call (D-N). + void SyncVaoAttributeBuffersByHandle(const SharedPtr& currentVAOObject, + VertexArrayImpl::BackendVertexArrayObject::ResolvedDrawBuffers* memo, + Uint64 bufferEpoch) { + const auto& st = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPipeHandle elements = st.BoundVertexElements; + Uint64 elementsSerial = 0; + Bool haveElementsRecord = false; + if (!MG_Pipe::MGPipeHandleIsNull(elements) && elements.Slot < st.VertexElementsCsos.size()) { + const auto& record = st.VertexElementsCsos[elements.Slot]; + if (record.Live && record.Gen == elements.Gen) { + elementsSerial = record.ContentSerial; + haveElementsRecord = true; + } + } + const Uint64 buffersSerial = st.VertexBuffersSerial; + + // NO LIVE ELEMENTS RECORD IS A MISS, NEVER A HIT. With none, the key above is + // {null, 0, buffersSerial} - a key that describes no configuration at all and that + // NEVER CHANGES while the state stays that way, so a memo stamped with it would hit + // on every later draw of a VAO whose attributes have moved. That state is reachable: + // MGPipeApplierReset() empties VertexElementsCsos and BoundVertexElements at every + // change of the current context, and the client re-emits only at its next + // create/bind. The legacy arm's configuration version caught exactly this by moving. + const Bool memoKeyIsMeaningful = haveElementsRecord; + + if (memo && memo->valid && memoKeyIsMeaningful && memo->elementsHandle == elements && + memo->elementsSerial == elementsSerial && memo->buffersSerial == buffersSerial) { + if (memo->vboCleanEpoch != bufferEpoch) { + Bool allClean = true; + for (Uint i = 0; i < memo->count; ++i) { + auto& entry = memo->entries[i]; + // entry.frontend is the same object the legacy arm probes and is kept + // alive by the VAO attribute's SharedPtr for as long as this memo is + // valid; it answers the live-map question no record carries in P3a. + if (IsBufferDrawCleanByHandle(entry.handle, entry.resource, entry.frontend)) continue; + allClean = false; + entry.resource = + EnsureBufferResource(currentVAOObject->GetAttribute(entry.attribIndex).Buffer); + } + memo->vboCleanEpoch = allClean ? bufferEpoch : 0; + } + return; + } + + // Full walk, once per distinct buffer, rebuilding the memo as it goes. + MG_State::GLState::BufferObject* syncedBuffers[MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS]; + Uint syncedBufferCount = 0; + const auto& allAttributes = currentVAOObject->GetAllAttributes(); + for (Uint attribIndex = 0; attribIndex < allAttributes.size(); ++attribIndex) { + const auto& attrib = allAttributes[attribIndex]; + if (!attrib.Enabled) continue; + const auto& bufferObject = attrib.Buffer; + if (!bufferObject) continue; + + auto* const bufferKey = bufferObject.get(); + Bool alreadySynced = false; + for (Uint i = 0; i < syncedBufferCount; ++i) { + if (syncedBuffers[i] == bufferKey) { + alreadySynced = true; + break; + } + } + if (alreadySynced) continue; + + auto* resource = EnsureBufferResource(bufferObject); + if (memo) { + auto& entry = memo->entries[syncedBufferCount]; + entry.frontend = bufferKey; + entry.attribIndex = static_cast(attribIndex); + entry.resource = resource; + entry.handle = HandleOfBuffer(bufferKey); + } + syncedBuffers[syncedBufferCount++] = bufferKey; + } + if (memo) { + memo->count = syncedBufferCount; + memo->elementsHandle = elements; + memo->elementsSerial = elementsSerial; + memo->buffersSerial = buffersSerial; + // Only a key that describes a real configuration is worth remembering; see + // memoKeyIsMeaningful above. The walk still ran and the buffers are ensured - + // this only refuses to let the NEXT draw skip it. + memo->valid = memoKeyIsMeaningful; + // Rebuilt via EnsureBufferResource, not probed clean: the next probe pass + // stamps the epoch. + memo->vboCleanEpoch = 0; + } + } + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (vi), CONTRACT-P5E §5.1: THE SAME MEMO, THE SAME THREE-VALUE KEY, AND A WALK THAT + // READS NO FRONTEND AT ALL. The arm above keeps the frontend attribute walk because the + // push-monolith build has to keep its bytes (§5.8); this one is what a server with no + // client objects on its side runs, and the difference between them is exactly three + // substitutions: + // + // the walk st.VertexBuffers[Start .. Start+Count) instead of + // GetAllAttributes(): the SAME set, because the emitter resolves the + // attributes on the client and publishes one entry per attribute slot + // (VertexInputEmit.h's EmitVertexBuffers), with Res == null for a + // client-memory array and for a disabled slot - which is why the null + // skip below is not a hole but the record's own "nothing to ensure". + // the dedupe a {slot, gen} compare instead of a raw address compare. An address + // a successor object can reproduce is the identity hazard this whole + // phase is about; a handle cannot be reproduced (Gen moves on reuse). + // the ensure EnsureBufferResourceForHandle(nullptr, Res). The frontend argument + // is only ever used for BufferObject::SyncPersistentMappedRange, which + // that function already skips under a transport (D-N), so passing + // nullptr removes a read rather than dropping work - and (nullptr, h) + // is already the live shape of the indirect-buffer ensure two hundred + // lines below. + // + // The IBO half lives in SyncNeccessaryBuffers beside the legacy one, for the reason it + // always did: the index slot is not part of the configuration (D5). + void SyncVaoAttributeBuffersByRecord( + VertexArrayImpl::BackendVertexArrayObject::ResolvedDrawBuffers* memo, Uint64 bufferEpoch) { + const auto& st = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPipeHandle elements = st.BoundVertexElements; + Uint64 elementsSerial = 0; + Bool haveElementsRecord = false; + if (!MG_Pipe::MGPipeHandleIsNull(elements) && elements.Slot < st.VertexElementsCsos.size()) { + const auto& record = st.VertexElementsCsos[elements.Slot]; + if (record.Live && record.Gen == elements.Gen) { + elementsSerial = record.ContentSerial; + haveElementsRecord = true; + } + } + const Uint64 buffersSerial = st.VertexBuffersSerial; + + // NO LIVE ELEMENTS RECORD IS A MISS, NEVER A HIT - the arm above states the whole + // argument (MGPipeApplierReset empties the table at every context change and the + // key would then never move again). + const Bool memoKeyIsMeaningful = haveElementsRecord; + + if (memo && memo->valid && memoKeyIsMeaningful && memo->elementsHandle == elements && + memo->elementsSerial == elementsSerial && memo->buffersSerial == buffersSerial) { + if (memo->vboCleanEpoch != bufferEpoch) { + Bool allClean = true; + for (Uint i = 0; i < memo->count; ++i) { + auto& entry = memo->entries[i]; + // nullptr, not entry.frontend: the frontend question inside + // IsBufferDrawCleanByHandle is monolith-only already + // (askTheObjectWhetherItIsMapped), and a raw client address held across + // records is precisely what §4.4 stops the apply thread dereferencing. + if (IsBufferDrawCleanByHandle(entry.handle, entry.resource, nullptr)) continue; + allClean = false; + // Re-ensured BY HANDLE. The legacy repair went back through the memoed + // attribute index to re-read attrib.Buffer; here the entry already + // names the resource the record named, so the repair needs nothing the + // walk did not already have. + entry.resource = EnsureBufferResourceForHandle(nullptr, entry.handle); + } + memo->vboCleanEpoch = allClean ? bufferEpoch : 0; + } + return; + } + + // Full walk, once per distinct buffer, rebuilding the memo as it goes. WHICH + // buffers is ResolveDrawVertexBuffersFromRecord's answer and only its answer - see + // Managers.h: that separation is what lets a unit case drive the source decision + // this package changed without an ES context, and what makes a revert of it visible + // rather than duplicated. + DrawVertexBufferRequest requests[MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS]; + const Uint syncedBufferCount = ResolveDrawVertexBuffersFromRecord( + st, requests, MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS); + for (Uint i = 0; i < syncedBufferCount; ++i) { + auto* resource = EnsureBufferResourceForHandle(nullptr, requests[i].Res); + if (memo) { + auto& entry = memo->entries[i]; + // Explicitly cleared rather than left as whatever the previous build of + // this memo stored: nothing on this arm may dereference it, and a stale + // address that merely happens never to be read is the kind of thing a + // later reader takes for a live one. + entry.frontend = nullptr; + entry.attribIndex = static_cast(requests[i].BindingIndex); + entry.resource = resource; + entry.handle = requests[i].Res; + } + } + if (memo) { + memo->count = syncedBufferCount; + memo->elementsHandle = elements; + memo->elementsSerial = elementsSerial; + memo->buffersSerial = buffersSerial; + memo->valid = memoKeyIsMeaningful; + // Rebuilt via the ensure, not probed clean: the next probe pass stamps it. + memo->vboCleanEpoch = 0; + } + } +#endif // MOBILEGL_BUILD_DISAGGREGATED +#endif // MOBILEGL_PIPE_PUSH + // `vaoConfigVersion` is the caller's early read of currentVAOObject->GetConfigVersion(): // the VAO's config fields live on a cache line the draw path touches nowhere else, and // cycling section VAOs makes that a guaranteed miss - reading it at the top of @@ -546,7 +1213,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // 1.VBO 2.IBO (if needed) 3.UBO 4.IndirectBuffer (if needed) // PBO is not needed since it should be handled in frontend - if (!currentVAOObject) { + // P5e (vi): on the record arm there is deliberately no frontend VAO to check - the + // question "is a VAO bound" is answered by st.BoundVertexElements at the twin + // resolve, and a null there already produced a null twin (and therefore a null + // memo) before this function was called. Asking for an object this side does not + // have would make every split draw log and return. + if (!currentVAOObject && !VertexInputReadsRecords()) { MGLOG_E_ONCE("No VAO is currently bound, cannot sync necessary buffers."); return; } @@ -567,6 +1239,16 @@ namespace MobileGL::MG_Backend::DirectGLES { const Uint64 bufferEpoch = CurrentBufferMutationEpoch(); auto* memo = vaoTwin ? &vaoTwin->GetResolvedDrawBuffersMemo() : nullptr; const Uint32 configVersion = vaoConfigVersion; +#if MOBILEGL_BUILD_DISAGGREGATED + if (VertexInputReadsRecords()) { + SyncVaoAttributeBuffersByRecord(memo, bufferEpoch); + } else +#endif +#if MOBILEGL_PIPE_PUSH + if (VertexInputSubsystemEnabled()) { + SyncVaoAttributeBuffersByHandle(currentVAOObject, memo, bufferEpoch); + } else +#endif if (memo && memo->valid && memo->configVersion == configVersion) { if (memo->vboCleanEpoch != bufferEpoch) { Bool allClean = true; @@ -631,24 +1313,88 @@ namespace MobileGL::MG_Backend::DirectGLES { // the config version). A stale identity hit is impossible in effect: the // clean probe re-validates the resource against the LIVE bound object. if (includeIBO) { - const auto& possibleIBO = currentVAOObject->GetIndexBufferBindingSlot().GetBoundObject(); - if (possibleIBO) { - // The epoch stamp alone is NOT enough here: the index slot can - // rebind another buffer with no epoch (and no config-version) move, - // so the identity compare always runs; only the clean PROBE is - // elided while the stamp holds. - if (memo && memo->iboFrontend == possibleIBO.get() && memo->iboCleanEpoch == bufferEpoch) { - // probed fully clean at this epoch; nothing can have dirtied it - } else if (memo && memo->iboFrontend == possibleIBO.get() && - IsBufferDrawClean(memo->iboFrontend, memo->iboResource)) { - memo->iboCleanEpoch = bufferEpoch; - } else { - auto* resource = EnsureBufferResource(possibleIBO); - if (memo) { - memo->iboFrontend = possibleIBO.get(); - memo->iboResource = resource; - // Repaired, not probed clean: stamp on the next clean probe. - memo->iboCleanEpoch = 0; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (vi), CONTRACT-P5E §5.1: the index buffer is st.IndexBuffer.Res and the + // "has it moved" question is st.IndexBufferSerial. The identity compare stays - + // it always did, because the index slot is outside the configuration version + // (D5) - but it now compares a handle the RECORD named instead of a frontend + // address the slot was probed for, and the serial joins it because this arm has + // no live slot to re-read: see ResolvedDrawBuffers::iboSerial in Managers.h. + // + // A null Res is "no element buffer bound" and is a no-op here exactly as the + // absent `possibleIBO` is below: SyncToBackendFromApplier is what binds + // GL_ELEMENT_ARRAY_BUFFER back to 0 for such a draw, and it keys on the same + // serial. + if (VertexInputReadsRecords()) { + const DrawIndexBufferRequest request = + ResolveDrawIndexBufferFromRecord(MG_Pipe::MGPipeApplier()); + const MG_Pipe::MGPipeHandle iboHandle = request.Res; + const Uint64 iboSerial = request.Serial; + if (!MG_Pipe::MGPipeHandleIsNull(iboHandle)) { + const Bool identityHolds = + memo != nullptr && memo->iboHandle == iboHandle && memo->iboSerial == iboSerial; + if (identityHolds && memo->iboCleanEpoch == bufferEpoch) { + // probed fully clean at this epoch; nothing can have dirtied it + } else if (identityHolds && + IsBufferDrawCleanByHandle(iboHandle, memo->iboResource, nullptr)) { + memo->iboCleanEpoch = bufferEpoch; + } else { + auto* resource = EnsureBufferResourceForHandle(nullptr, iboHandle); + if (memo) { + memo->iboHandle = iboHandle; + memo->iboSerial = iboSerial; + memo->iboFrontend = nullptr; // see Entry::frontend's note + memo->iboResource = resource; + // Repaired, not probed clean: stamp on the next clean probe. + memo->iboCleanEpoch = 0; + } + } + } + } else +#endif + { + const auto& possibleIBO = currentVAOObject->GetIndexBufferBindingSlot().GetBoundObject(); + if (possibleIBO) { + // The epoch stamp alone is NOT enough here: the index slot can + // rebind another buffer with no epoch (and no config-version) move, + // so the identity compare always runs; only the clean PROBE is + // elided while the stamp holds. +#if MOBILEGL_PIPE_PUSH + if (ResourceSubsystemEnabled()) { + // Same three cases, with the identity re-keyed off the raw frontend + // address onto the resource's {slot, gen} (D-G4). + const MG_Pipe::MGPipeHandle iboHandle = HandleOfBuffer(possibleIBO.get()); + if (memo && memo->iboHandle == iboHandle && memo->iboCleanEpoch == bufferEpoch) { + // probed fully clean at this epoch; nothing can have dirtied it + } else if (memo && memo->iboHandle == iboHandle && + IsBufferDrawCleanByHandle(iboHandle, memo->iboResource, + possibleIBO.get())) { + memo->iboCleanEpoch = bufferEpoch; + } else { + auto* resource = EnsureBufferResource(possibleIBO); + if (memo) { + memo->iboHandle = iboHandle; + memo->iboFrontend = possibleIBO.get(); + memo->iboResource = resource; + // Repaired, not probed clean: stamp on the next clean probe. + memo->iboCleanEpoch = 0; + } + } + } else +#endif + if (memo && memo->iboFrontend == possibleIBO.get() && memo->iboCleanEpoch == bufferEpoch) { + // probed fully clean at this epoch; nothing can have dirtied it + } else if (memo && memo->iboFrontend == possibleIBO.get() && + IsBufferDrawClean(memo->iboFrontend, memo->iboResource)) { + memo->iboCleanEpoch = bufferEpoch; + } else { + auto* resource = EnsureBufferResource(possibleIBO); + if (memo) { + memo->iboFrontend = possibleIBO.get(); + memo->iboResource = resource; + // Repaired, not probed clean: stamp on the next clean probe. + memo->iboCleanEpoch = 0; + } } } } @@ -657,10 +1403,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // Indirect Buffer Object - must also be bound to GL_DRAW_INDIRECT_BUFFER on the ES // context since indirect draws now execute natively on the GPU. if (includeIndirectBuffer) { - auto& possibleIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); - if (possibleIndirectBuffer) { - SyncBoundBuffer(BufferTarget::DrawIndirect, GL_DRAW_INDIRECT_BUFFER); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): under an active transport the buffer is the verb + // record's handle; the frontend binding slot and the client allocator are + // never read (T2). The twin's ensure is what SyncBoundBuffer's did. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeApplier().VerbIndirectBuffer; + if (!MG_Pipe::MGPipeHandleIsNull(handle)) { + auto* resource = EnsureBufferResourceForHandle(nullptr, handle); + if (resource && resource->id != 0) { + BindBufferId(GL_DRAW_INDIRECT_BUFFER, resource->id); + } + } + } else +#endif + { + auto& possibleIndirectBuffer = + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + if (possibleIndirectBuffer) { + SyncBoundBuffer(BufferTarget::DrawIndirect, GL_DRAW_INDIRECT_BUFFER); + } } } @@ -674,7 +1436,17 @@ namespace MobileGL::MG_Backend::DirectGLES { // SSBOs are different: their block bindings are baked into the ESSL at compile time and // BindCurrentProgramWithResources binds no SSBO points, so this is their sole draw-path // binder (e.g. Flywheel's indirect vertex shaders pull instance data from storage buffers). - SyncBufferBindingPoints(BufferTarget::ShaderStorage, GL_SHADER_STORAGE_BUFFER); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (sb, §5.6): the record arm, selected by transport + bit 13. Same `if (…) else` + // shape the indirect buffer above already uses. + if (BindingPointsComeFromRecords()) { + SyncBufferBindingPointsByRecord(MG_Pipe::kMGPipeShaderBufferClassShaderStorage, + GL_SHADER_STORAGE_BUFFER); + } else +#endif + { + SyncBufferBindingPoints(BufferTarget::ShaderStorage, GL_SHADER_STORAGE_BUFFER); + } MarkShaderStorageBuffersGpuWritten(); } @@ -683,10 +1455,37 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif ProcessDeferredBufferReleases(); - SyncBufferBindingPoints(BufferTarget::Uniform, GL_UNIFORM_BUFFER); - SyncBufferBindingPoints(BufferTarget::ShaderStorage, GL_SHADER_STORAGE_BUFFER); + // THE COMPUTE PATH IS THE ONE THAT NEEDS THE FRONTEND-INDEXED UNIFORM PASS, and it + // is why the uniform class is emitted at all: compute does NOT go through the + // per-program block remap in BindCurrentProgramWithResources, so these are the + // points a compute shader actually reads. +#if MOBILEGL_BUILD_DISAGGREGATED + if (BindingPointsComeFromRecords()) { + SyncBufferBindingPointsByRecord(MG_Pipe::kMGPipeShaderBufferClassUniform, + GL_UNIFORM_BUFFER); + SyncBufferBindingPointsByRecord(MG_Pipe::kMGPipeShaderBufferClassShaderStorage, + GL_SHADER_STORAGE_BUFFER); + } else +#endif + { + SyncBufferBindingPoints(BufferTarget::Uniform, GL_UNIFORM_BUFFER); + SyncBufferBindingPoints(BufferTarget::ShaderStorage, GL_SHADER_STORAGE_BUFFER); + } MarkShaderStorageBuffersGpuWritten(); if (includeDispatchIndirectBuffer) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): see the draw-indirect twin above - the verb + // record's handle, never the frontend binding slot or the client allocator. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle handle = MG_Pipe::MGPipeApplier().VerbDispatchIndirectBuffer; + if (!MG_Pipe::MGPipeHandleIsNull(handle)) { + auto* resource = EnsureBufferResourceForHandle(nullptr, handle); + if (resource && resource->id != 0) { + BindBufferId(GL_DISPATCH_INDIRECT_BUFFER, resource->id); + } + } + } else +#endif SyncBoundBuffer(BufferTarget::DispatchIndirect, GL_DISPATCH_INDIRECT_BUFFER); } } @@ -813,7 +1612,50 @@ namespace MobileGL::MG_Backend::DirectGLES { } if (g_GLESFuncs.glMapBufferRange != nullptr && g_GLESFuncs.glUnmapBuffer != nullptr) { for (const auto& target : targets) { - if (!target.buffer || target.buffer->IsBackendPersistentMapped()) continue; + if (!target.buffer) continue; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c: under an active transport the frontend object is client + // memory (rule E), so the persistence question is the server + // resource's and the captured bytes go back as a writeback EVENT - + // WritebackFromBackend from the apply thread is the R1/R2 shape. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle res = BufferImpl::HandleOfBuffer(target.buffer.get()); + auto* resource = BufferImpl::FindBufferResourceForHandle(res); + if (resource == nullptr || resource->persistentMapped) continue; + const SizeT size = target.end - target.start; + BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, target.backendId); + void* mapped = g_GLESFuncs.glMapBufferRange(BufferImpl::TempBufferTarget, + static_cast(target.start), + static_cast(size), GL_MAP_READ_BIT); + if (mapped == nullptr) { + MGLOG_E_ONCE("EndTransformFeedback: failed to map backend buffer %u [%zu, %zu) for " + "capture readback (ES error %s); the captured data will NOT be visible to " + "the application", + target.backendId, target.start, target.end, + MG_Util::ConvertGLEnumToString(TakeXfbDriverError()).c_str()); + continue; + } + if (MG_Pipe::gMGPipeCallbacks.OnBufferWriteback != nullptr) { + MG_Pipe::gMGPipeCallbacks.OnBufferWriteback( + res, target.start, + MG_Pipe::MGPBlobRef{reinterpret_cast(mapped), + static_cast(size), + MG_Pipe::kMGHostSpanSegNone, 0}); + } else { + MGLOG_E_ONCE("EndTransformFeedback: no reverse channel is installed, so the " + "captured bytes of buffer {%u,%u} cannot reach the client shadow", + res.Slot, res.Gen); + } + g_GLESFuncs.glUnmapBuffer(BufferImpl::TempBufferTarget); + // Ops_H_Readback's order: writeback, unmap, THEN the serial + // stamp, so the next draw does not re-upload the server's stale + // staged bytes over the capture that just landed. + resource->syncedChangeSerial = BufferImpl::ResourceSerialForHandle(res); + BufferImpl::BumpBufferMutationEpoch(); + continue; + } +#endif + if (target.buffer->IsBackendPersistentMapped()) continue; const SizeT size = target.end - target.start; BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, target.backendId); void* mapped = g_GLESFuncs.glMapBufferRange(BufferImpl::TempBufferTarget, @@ -888,7 +1730,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT packedStride = program->GetTransformFeedbackPackedStride(); const SizeT modelledVertices = - static_cast(MG_State::pGLContext->GetTransformFeedbackCapturedVertices()); + static_cast(MGB_CTX->GetTransformFeedbackCapturedVertices()); const SizeT vertices = std::min(modelledVertices, xfb.scatterCapacityVertices); if (packedStride == 0 || vertices == 0) { // The scatter path redirected the DRIVER's capture into the scratch buffer, @@ -925,6 +1767,30 @@ namespace MobileGL::MG_Backend::DirectGLES { if (stride == 0) continue; const SizeT rangeBytes = target.end - target.start; Vector staged(rangeBytes); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c: under an active transport BufferObject::MappedData on the apply + // thread is Fatal{RoleViolation, "buffer-legacy-arm"} (CONTRACT-P5C §3.8) - + // the pre-capture bytes are the SERVER's staged shadow, and the reconciled + // range goes back as a writeback EVENT instead of a WritebackFromBackend + // poke into client memory. + MG_Pipe::MGPipeHandle splitRes = MG_Pipe::kMGPipeNullHandle; + BufferImpl::GLESBufferResource* splitResource = nullptr; + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + splitRes = BufferImpl::HandleOfBuffer(target.buffer.get()); + splitResource = BufferImpl::FindBufferResourceForHandle(splitRes); + const Uint8* hostBytes = splitResource != nullptr ? splitResource->hostBytes : nullptr; + if (splitResource == nullptr || hostBytes == nullptr) { + MGLOG_E_ONCE("EndTransformFeedback: a scattered capture target (handle {%u,%u}) has " + "no server shadow to read the pre-capture bytes from; its capture is " + "discarded", + splitRes.Slot, splitRes.Gen); + continue; + } + BufferImpl::RequireStagedCoverage(*splitResource, hostBytes, target.start, target.end, + "xfb_scatter_pre_capture"); + Memcpy(staged.data(), hostBytes + target.start, rangeBytes); + } else +#endif Memcpy(staged.data(), target.buffer->MappedData() + target.start, rangeBytes); for (const auto& varying : program->GetTransformFeedbackVaryings()) { @@ -938,6 +1804,25 @@ namespace MobileGL::MG_Backend::DirectGLES { } } +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + if (MG_Pipe::gMGPipeCallbacks.OnBufferWriteback != nullptr) { + MG_Pipe::gMGPipeCallbacks.OnBufferWriteback( + splitRes, target.start, + MG_Pipe::MGPBlobRef{reinterpret_cast(staged.data()), + static_cast(rangeBytes), + MG_Pipe::kMGHostSpanSegNone, 0}); + } else { + MGLOG_E_ONCE("EndTransformFeedback: no reverse channel is installed, so the " + "scattered capture of buffer {%u,%u} cannot reach the client shadow", + splitRes.Slot, splitRes.Gen); + } + // Ops_H_Readback's stamp, for its reason: the GL store now holds + // bytes the server's staged shadow does not, and the next draw must + // not re-upload the stale shadow over the capture. + splitResource->syncedChangeSerial = BufferImpl::ResourceSerialForHandle(splitRes); + } else +#endif target.buffer->WritebackFromBackend({staged.data(), rangeBytes}, target.start); // Serial bumped with no backend op (see ReadbackCapturedRanges). BufferImpl::BumpBufferMutationEpoch(); @@ -983,7 +1868,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // not captured, and opening the span would also subject it to the capture // primitive-mode rule the paused draw is exempt from. if (!xfb.pending || xfb.paused) return; - const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram(); + const auto& program = MGB_CTX->GetTransformFeedbackProgram(); if (!program) { // The pending flag is deliberately NOT consumed here. It used to be cleared // before this check, so a single draw that could not see the capture program @@ -1003,7 +1888,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // recording it here keeps End independent of the frontend capture state. const SizeT bufferCount = program->GetTransformFeedbackBufferCount(); for (SizeT i = 0; i < bufferCount; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::TransformFeedback, static_cast(i)); const auto& bufferObject = point.GetBoundObject(); if (!bufferObject) continue; @@ -1194,12 +2079,37 @@ namespace MobileGL::MG_Backend::DirectGLES { // the result to the buffer sync (resolved-buffers memo host), the VAO sync and // the draw-time bind, which each used to run their own registry Find. The raw // pointer stays valid for the whole draw: the frontend VAO is pinned by the - // context binding, and a live object's registry entry is never erased nor its - // twin replaced (see TwinLookupMemo's contract). + // context binding, and a live object's twin is never erased nor replaced - on the + // legacy arm that is TwinLookupMemo's contract, and on the {slot, gen} arm it is + // simply that nothing but the sweep frees a slot and the sweep only takes slots + // whose frontend object is already gone. + // MONOLITH GLUE AS OF P5e (vi), CONTRACT-P5E §4.1 / §5.8, AND THE SCOPE IS GONE WITH THE + // DEBT IT NAMED. This overload is reached only when there IS a frontend VAO to resolve + // from, i.e. from the push-monolith and legacy arms of PrepareForDraw; a live transport + // takes ResolveVaoTwin(st.BoundVertexElements) instead (Managers.cpp, id's body). The + // MGPipeFrontendKeyedRegistryScope that used to wrap this - one of the three scope sites + // this family owned - is DELETED rather than narrowed, and that deletion is what makes + // the revert loud: putting `Find(vao.get())` back on the transport arm now aborts + // Fatal{RoleViolation, "MGPipeSlots"} from HandleOf instead of being quietly exempted. BackendVertexArrayObject* ResolveVaoTwin(const SharedPtr& vao) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // No memo on this arm. The memo existed to turn the registry's hash probe into + // an array index; the slot table's Find already IS the array index, and the + // memo's whole safety argument - owner-equality against a recycled heap address + // - is answered by {slot, gen} instead of re-derived per lookup. + auto* slot = g_backendVertexArrayObjects.Find(vao.get()); + auto& backendObj = slot ? *slot : g_backendVertexArrayObjects.GetOrCreate(vao); + if (!backendObj) { + backendObj = MakeShared(); + } + return backendObj.get(); + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (auto* twin = g_vaoTwinLookupMemo.Lookup(vao)) { return twin; } @@ -1210,6 +2120,9 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_vaoTwinLookupMemo.Store(vao, backendObj.get()); return backendObj.get(); +#else + return nullptr; +#endif } void SyncCurrentVAO(const SharedPtr& currentVAOObject, @@ -1227,6 +2140,84 @@ namespace MobileGL::MG_Backend::DirectGLES { vaoTwin->SyncToBackend(currentVAOObject); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (vi): the record arm's entry, and it takes NO frontend object - CONTRACT-P5E §4.1 + // ("the object parameter is deleted from the transport overload, not defaulted to + // null"). A null twin here is the null BoundVertexElements, i.e. "no VAO bound", which + // PrepareForDraw turns into BindBackendVAOId(0) exactly as a null frontend VAO did; + // logging about it would fire on every draw of a session that legitimately has no + // vertex-elements CSO yet. + void SyncCurrentVAOFromRecords(BackendVertexArrayObject* vaoTwin) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + // Kept for symmetry with the overload above and free on this arm: the slot table's + // sweep is a no-op (Managers.h's CollectGarbageIfNeeded on the handle registry). + g_backendVertexArrayObjects.CollectGarbageIfNeeded(); + if (!vaoTwin) return; + vaoTwin->SyncToBackendFromApplier(); + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + + // THE CLIENT-MEMORY VERTEX ARRAY UPLOAD, and the one read of this family that P5e does + // NOT retire - it MOVES the decision instead, which is what ID-82 / ruling 2 settles. + // + // The bytes of a client-memory array exist only in the application's own memory: the + // emitter publishes Res == kMGPipeNullHandle for such an attribute by design + // (VertexInputEmit.h) and this upload is where the server used to dereference + // attrib.Offset as a raw client pointer (Managers.cpp's + // SyncClientSideAttributesForDrawArrays, kimi audit row 14). Under run-ahead those bytes + // are movable and the read is torn by construction, so the CLIENT refuses the draw by + // name before it is ever emitted (EmitTables.cpp, Fatal{UnmigratedVerb, + // "DrawArrays+CLIENT_ARRAYS"}); staging them as a record tail is P8's. + // + // What is left here is therefore reachable in exactly two situations, and both are safe: + // * monolith / the push-monolith build - one process, one thread, no wire; + // * a LOCKSTEP split session - the client is parked in WaitForApplied for + // this very record, so its memory is stable + // (rule F is scoped to unbarriered records, + // ruling 4). Escalation (ii) of §2.1 pins the + // same thing from the other side: a draw + // carrying kDrawClientArrays is BARRIERED. + // Making it monolith-only instead would silently drop the attribute upload of a + // lockstep split session, which is a wrong picture today - before ra has landed + // anything - so the refusal is the client's to raise, not this arm's to assume. + // + // The frontend VAO is taken ONLY when the applier's own vertex-buffer window says a + // client-sourced attribute exists, which is what retires the per-draw + // GetBoundVertexArray row of the ordinary (VBO-backed) DrawArrays - the strict lane's + // `GetBoundVertexArray@DrawArrays` marker - without touching this path's behaviour. + // `static` on purpose (G1): the pull build must not gain an exported name for a + // function that only re-homes two identical inline blocks the two DrawArrays entry + // points used to carry. + static void SyncClientSideVertexArraysForDrawArrays(GLint first, GLsizei count) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (BufferImpl::VertexInputReadsRecords()) { + if (!BufferImpl::AnyClientSideVertexArrayInRecord()) return; + if (MG_Pipe::MGPipeApplierIsUnbarrieredApply()) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"MGPipeSlots\"} - a draw carrying a " + "client-memory vertex array is being applied UNBARRIERED. Both the " + "client's refusal (Fatal{UnmigratedVerb, \"DrawArrays+CLIENT_ARRAYS\"}) " + "and escalation (ii) of CONTRACT-P5E §2.1 exist to make this " + "unreachable; the bytes below are the application's and are moving"); + std::abort(); + } + auto* twin = ResolveVaoTwin(MG_Pipe::MGPipeApplier().BoundVertexElements); + if (twin == nullptr) return; + const auto& currentVAO = MGB_CTX->GetBoundVertexArray(); + if (!currentVAO) return; + twin->SyncClientSideAttributesForDrawArrays(currentVAO, first, count); + return; + } +#endif + const auto& currentVAO = MGB_CTX->GetBoundVertexArray(); + if (!currentVAO) return; + auto* backendVAOSlot = g_backendVertexArrayObjects.Find(currentVAO.get()); + if (backendVAOSlot && *backendVAOSlot) { + (*backendVAOSlot)->SyncClientSideAttributesForDrawArrays(currentVAO, first, count); + } + } + // GL: a shader input whose generic attribute array is DISABLED reads that attribute's *current // value* (context state set by glVertexAttrib*, default (0,0,0,1)) rather than any buffer. // MobileGL stores those values in MG_State only, so without this step the ES driver would feed @@ -1239,9 +2230,50 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (!program) return; + if (!vaoTwin) return; + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (vi), CONTRACT-P5E §5.1: the bound-VAO row is retired here. On the record arm + // the twin IS the bound vertex-elements CSO (the caller resolved it from + // st.BoundVertexElements), so "is a VAO bound" is already answered by vaoTwin being + // non-null and GetBoundVertexArray() has nothing left to add. + // + // WHAT IS NOT RETIRED HERE, and it is S4/pg's not vi's: the two program reads below + // (GetActiveAttributeLocationMask, GetAttribType). They are frontend object rows of + // the PROGRAM family and stay until pg carries the attribute mask and types in the + // ShaderCso record. Until then this function is still a frontend reader on the + // apply thread - a BARRIERED one, which is legal (§4.4 / ruling 4) and is why vi + // does its half now rather than waiting: the VAO half is what this package owns and + // leaving it would keep GetBoundVertexArray alive for a reason that is not the + // program's. + const Bool fromRecords = BufferImpl::VertexInputReadsRecords(); + const MG_Pipe::MGPipeVertexElementsRecord* elementsRecord = nullptr; + MG_Pipe::MGPipeHandle elementsHandle = MG_Pipe::kMGPipeNullHandle; + Uint64 elementsSerial = 0; + if (fromRecords) { + const auto& st = MG_Pipe::MGPipeApplier(); + elementsHandle = st.BoundVertexElements; + if (!MG_Pipe::MGPipeHandleIsNull(elementsHandle) && + elementsHandle.Slot < st.VertexElementsCsos.size()) { + const auto& record = st.VertexElementsCsos[elementsHandle.Slot]; + if (record.Live && record.Gen == elementsHandle.Gen) { + elementsRecord = &record; + elementsSerial = record.ContentSerial; + } + } + // No record is "the configuration was never described": there is nothing to + // decide which locations lack an array from, and guessing would either + // re-issue every current value every draw or silently skip the ones that need + // it. The next create_vertex_elements re-opens the memo by serial. + if (elementsRecord == nullptr) return; + } +#else + constexpr Bool fromRecords = false; +#endif - const auto& vao = MG_State::pGLContext->GetBoundVertexArray(); - if (!vao || !vaoTwin) return; + const SharedPtr noVao; + const auto& vao = fromRecords ? noVao : MGB_CTX->GetBoundVertexArray(); + if (!fromRecords && !vao) return; const Uint32 activeAttribMask = program->GetActiveAttributeLocationMask(); if (activeAttribMask == 0) return; @@ -1254,24 +2286,57 @@ namespace MobileGL::MG_Backend::DirectGLES { // cycled section VAOs, re-reading the cold attribute slots each time; here a // cycle re-hits every VAO's own entry. The rebuild visits only ACTIVE locations. auto& memo = vaoTwin->GetPendingAttribValueMaskMemo(); - const Uint32 configVersion = vao->GetConfigVersion(); - if (!memo.valid || configVersion != memo.configVersion || activeAttribMask != memo.activeMask) { - Uint32 pending = 0; - for (Uint32 remaining = activeAttribMask; remaining != 0; remaining &= remaining - 1) { - const Uint32 location = static_cast(std::countr_zero(remaining)); - if (!vao->GetAttribute(location).Enabled) pending |= (1u << location); - } - memo.configVersion = configVersion; - memo.activeMask = activeAttribMask; - memo.pendingMask = pending; - memo.valid = true; +#if MOBILEGL_BUILD_DISAGGREGATED + if (fromRecords) { + // The re-keyed memo: {elementsHandle, ContentSerial, activeMask}. Every + // Enable/DisableVertexAttribArray moves the frontend configuration version, the + // client re-emits create_vertex_elements on the same handle for it, and the + // applier ++s ContentSerial - so this key opens exactly when the old one did + // and never wraps (Managers.h states the strictly-stronger argument). + if (!memo.valid || !(memo.elementsHandle == elementsHandle) || + memo.elementsSerial != elementsSerial || activeAttribMask != memo.activeMask) { + Uint32 pending = 0; + for (Uint32 remaining = activeAttribMask; remaining != 0; remaining &= remaining - 1) { + const Uint32 location = static_cast(std::countr_zero(remaining)); + // rec->Attributes[] IS what was last pushed for this configuration; the + // record carries all 32 slots and zeroes the tail + // (MGPipeApplyCreateVertexElements), so a location past AttributeCount + // reads Enabled = 0, which is the same answer the frontend's cold + // attribute slot gave. + if (location >= MG_Pipe::kMGPipeMaxVertexAttribs || + !elementsRecord->Attributes[location].Enabled) { + pending |= (1u << location); + } + } + memo.elementsHandle = elementsHandle; + memo.elementsSerial = elementsSerial; + memo.activeMask = activeAttribMask; + memo.pendingMask = pending; + memo.valid = true; + } + } else +#endif + { + const Uint32 configVersion = vao->GetConfigVersion(); + if (!memo.valid || configVersion != memo.configVersion || + activeAttribMask != memo.activeMask) { + Uint32 pending = 0; + for (Uint32 remaining = activeAttribMask; remaining != 0; remaining &= remaining - 1) { + const Uint32 location = static_cast(std::countr_zero(remaining)); + if (!vao->GetAttribute(location).Enabled) pending |= (1u << location); + } + memo.configVersion = configVersion; + memo.activeMask = activeAttribMask; + memo.pendingMask = pending; + memo.valid = true; + } } if (memo.pendingMask == 0) return; for (Uint32 remaining = memo.pendingMask; remaining != 0; remaining &= remaining - 1) { const Uint32 location = static_cast(std::countr_zero(remaining)); - const auto& currentValue = MG_State::pGLContext->GetCurrentVertexAttribute(location); + const auto& currentValue = MGB_CTX->GetCurrentVertexAttribute(location); const auto typeInfo = MG_State::GLState::ClassifyVertexAttribType(program->GetAttribType(location)); switch (typeInfo.baseType) { case MG_State::GLState::VertexAttribBaseType::Float: @@ -1291,49 +2356,252 @@ namespace MobileGL::MG_Backend::DirectGLES { } } } - } // namespace VertexArrayImpl - namespace TextureImpl { - SharedPtr& SyncTextureObjectToBackend( - const SharedPtr& textureObject, - Bool imageBindableStorageRequired) { +#if MOBILEGL_PIPE_PUSH + // P5e (pa), CONTRACT-P5E §5.5 / §5.8 (ruling ID-81): THE SAME SYNC, OFF THE RECORD. + // + // The overload above is the MONOLITH GLUE and keeps its text token for token; this one is + // what the handle arm calls, and it answers the note vi left inside it - "the two program + // reads below (GetActiveAttributeLocationMask, GetAttribType) ... stay until pg carries + // the attribute mask and types in the ShaderCso record". pg's archive carries both: + // `attribs` and `attribTypes` are members of LinkArtifacts, the record ADOPTS the whole of + // it at create_shader_state, and neither is one of the three post-link mutable fields the + // bindings tails exist for (glUniformBlockBinding / glUniform1i on a sampler / + // glShaderStorageBlockBinding move those, and nothing moves these without a relink, which + // re-issues the create). So this arm adds NOTHING to the wire and changes no record - it + // reads rows that already arrived. + // + // WHY A SECOND FUNCTION rather than an `#if` inside the body above: §5.8's idiom, and G1 - + // the frontend overload's preprocessed text does not move, so the pull build gains no + // symbol and no byte. The price is that vi's memo arm is COPIED rather than shared; a + // shared tail would have rewritten the pull build's one, which G1 measures at 0/0/0/0. + void SyncCurrentVertexAttributeValues(BackendVertexArrayObject* vaoTwin, MG_Pipe::MGPipeHandle cso) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto* backendTextureSlot = g_backendTextureObjects.Find(textureObject.get()); - auto& backendSlot = backendTextureSlot ? *backendTextureSlot - : g_backendTextureObjects.GetOrCreate(textureObject); - if (!backendSlot) { - backendSlot = MakeShared(); + // THE SAME THREE-PART TEST SyncCurrentProgramByHandle MAKES, from the descriptor, and + // it is this arm's spelling of `if (!program) return;` above rather than a weaker one. + // LinkStatus is a FIELD and not an implication (ID-88): create_shader_state is + // re-issued at every link that moves the link version and a FAILED relink of a bound + // program moves it too, so "a record exists" and "the program linked" are different + // statements. A program that did not link, or whose SPIR-V never arrived, is one the + // same Prepare has just bound program 0 for - there is no shader to feed a current + // generic attribute to, which is what the frontend arm gets from an unlinked + // program's empty `attribs`. + const MG_Pipe::MGPipeShaderCsoRecord* const record = PipeShaderCsoRecordForHandle(cso); + if (record == nullptr || record->Desc.LinkStatus == 0 || record->Desc.SpirvStatus == 0) { + return; + } + // A NULL ARCHIVE ON THIS ARM IS A NAMED REFUSAL, NEVER A FALL-BACK TO THE FRONTEND - + // the shape of RefuseNullFrontendTextureOffTheHandleArm / MGB_TEXTURE_RECORD_ARM_- + // SELECTED (Managers.cpp, ID-110). This function is reached from ProgramHandleArm() + // only, i.e. Transport != Monolith AND the program subsystem bit, and under a + // transport pg fills Archive at every create_shader_state; so a LIVE record with a + // LINKED descriptor and no archive is a seam defect and not a state a draw may run + // in. Reaching back for GetProgramForDraw() here would answer the two rows out of the + // client's own live LinkArtifacts - the memory Link() replaces in place, i.e. exactly + // the read this package retires - and would do it behind a picture that still looks + // right, which is what the subsystem A/B exists to expose. + if (!record->Archive) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"program-handle-arm\"} - " + "SyncCurrentVertexAttributeValues needs the active-attribute mask and the " + "attribute types of ShaderCso {%u, %u} and that record carries no " + "server-owned archive. The arm is selected by Transport != Monolith AND " + "the program subsystem bit (CONTRACT-P5E §5.8, ID-81), so this record's " + "create_shader_state was applied under a transport and must have adopted " + "one; the frontend ProgramObject is not a fall-back here, it IS the client " + "memory this row retires", + cso.Slot, cso.Gen); + std::abort(); + } + const MG_State::GLState::LinkArtifacts& link = record->Archive->Link; + if (!vaoTwin) return; + + // vi's half, unchanged in meaning and copied rather than shared (see above). The two + // family bits are independent A/Bs, so a program on the handle arm says nothing about + // whether the vertex-input family is - this arm has to carry both of vi's. + const Bool fromRecords = BufferImpl::VertexInputReadsRecords(); + const MG_Pipe::MGPipeVertexElementsRecord* elementsRecord = nullptr; + MG_Pipe::MGPipeHandle elementsHandle = MG_Pipe::kMGPipeNullHandle; + Uint64 elementsSerial = 0; + if (fromRecords) { + const auto& st = MG_Pipe::MGPipeApplier(); + elementsHandle = st.BoundVertexElements; + if (!MG_Pipe::MGPipeHandleIsNull(elementsHandle) && + elementsHandle.Slot < st.VertexElementsCsos.size()) { + const auto& elements = st.VertexElementsCsos[elementsHandle.Slot]; + if (elements.Live && elements.Gen == elementsHandle.Gen) { + elementsRecord = &elements; + elementsSerial = elements.ContentSerial; + } + } + if (elementsRecord == nullptr) return; } - // A by-VALUE copy of the twin for the duration of the syncs below. `backendSlot` is a - // reference INTO the open-addressed registry, and syncing can RE-ENTER this function: - // a texture created by glTextureView has to sync the texture whose storage it views - // first (SyncTextureViewToBackend), and that nested call may insert, grow the map and - // relocate every entry - leaving the reference dangling. Holding the object itself - // keeps the calls below working on the right twin regardless; the slot is re-resolved - // at the end for the reference this function returns. - const SharedPtr backendObj = backendSlot; + const SharedPtr noVao; + const auto& vao = fromRecords ? noVao : MGB_CTX->GetBoundVertexArray(); + if (!fromRecords && !vao) return; - if (imageBindableStorageRequired) { - backendObj->RequireImageBindableStorage(textureObject); + // ProgramObject::GetActiveAttributeLocationMask, over the archive's own `attribs`: + // the same 32-location bound and the same "an empty name is not an active attribute" + // rule, computed where the names live instead of through an accessor MG_Backend + // cannot reach without a frontend object. + Uint32 activeAttribMask = 0; + { + const SizeT attribCount = std::min(link.attribs.size(), 32); + for (SizeT index = 0; index < attribCount; ++index) { + if (!link.attribs[index].empty()) activeAttribMask |= (1u << index); + } } - backendObj->SyncTextureParamsToBackend(textureObject); - backendObj->SyncBuiltinSamplerToBackend(textureObject); - backendObj->SyncMipmapsToBackend(textureObject); - // The storage sync may RE-MINT the driver texture - a fresh glTexStorage after a - // shape change, an image-bindable widening, or the glTextureView that an - // ARB_texture_view view is created on - which discards every parameter the two calls - // above just pushed. Re-push them here rather than leaving it to the next sync: the - // very next thing that happens is usually the draw this sync was run for, and until - // the filters land the new texture is at the ES defaults, which for a single-level or - // integer texture is not merely mis-filtered but INCOMPLETE, i.e. it samples zero. + if (activeAttribMask == 0) return; + + auto& memo = vaoTwin->GetPendingAttribValueMaskMemo(); + if (fromRecords) { + if (!memo.valid || !(memo.elementsHandle == elementsHandle) || + memo.elementsSerial != elementsSerial || activeAttribMask != memo.activeMask) { + Uint32 pending = 0; + for (Uint32 remaining = activeAttribMask; remaining != 0; remaining &= remaining - 1) { + const Uint32 location = static_cast(std::countr_zero(remaining)); + if (location >= MG_Pipe::kMGPipeMaxVertexAttribs || + !elementsRecord->Attributes[location].Enabled) { + pending |= (1u << location); + } + } + memo.elementsHandle = elementsHandle; + memo.elementsSerial = elementsSerial; + memo.activeMask = activeAttribMask; + memo.pendingMask = pending; + memo.valid = true; + } + } else { + const Uint32 configVersion = vao->GetConfigVersion(); + if (!memo.valid || configVersion != memo.configVersion || + activeAttribMask != memo.activeMask) { + Uint32 pending = 0; + for (Uint32 remaining = activeAttribMask; remaining != 0; remaining &= remaining - 1) { + const Uint32 location = static_cast(std::countr_zero(remaining)); + if (!vao->GetAttribute(location).Enabled) pending |= (1u << location); + } + memo.configVersion = configVersion; + memo.activeMask = activeAttribMask; + memo.pendingMask = pending; + memo.valid = true; + } + } + if (memo.pendingMask == 0) return; + + for (Uint32 remaining = memo.pendingMask; remaining != 0; remaining &= remaining - 1) { + const Uint32 location = static_cast(std::countr_zero(remaining)); + + // BOUNDS-CHECKED, exactly as ProgramObject::GetAttribType is not: the frontend's + // form indexes attribTypes raw because the mask it walked came off the same + // artefacts instance, and here the two vectors arrived over a wire. A short tail + // is an archive whose halves disagree; 0 classifies as Unsupported and says so + // below rather than reading past the end. + const GLenum attribType = + location < link.attribTypes.size() ? link.attribTypes[location] : 0; + const auto& currentValue = MGB_CTX->GetCurrentVertexAttribute(location); + const auto typeInfo = MG_State::GLState::ClassifyVertexAttribType(attribType); + switch (typeInfo.baseType) { + case MG_State::GLState::VertexAttribBaseType::Float: + g_GLESFuncs.glVertexAttrib4fv(location, currentValue.floatValue.data()); + break; + case MG_State::GLState::VertexAttribBaseType::Int: + g_GLESFuncs.glVertexAttribI4iv(location, currentValue.intValue.data()); + break; + case MG_State::GLState::VertexAttribBaseType::Uint: + g_GLESFuncs.glVertexAttribI4uiv(location, currentValue.uintValue.data()); + break; + case MG_State::GLState::VertexAttribBaseType::Unsupported: + // THE HANDLE, NOT A GL NAME: the server does not know the client's program + // names and must not learn them (ProgramArchiveSource::Identity states the + // same rule for the twin's log lines). + MGLOG_E_ONCE("SyncCurrentVertexAttributeValues: ShaderCso {%u, %u} location=%u has no " + "enabled array and its shader input type 0x%x is not supported as a current " + "generic vertex attribute", + cso.Slot, cso.Gen, location, attribType); + break; + } + } + } +#endif + } // namespace VertexArrayImpl + + namespace TextureImpl { + SharedPtr& SyncTextureObjectToBackend( + const SharedPtr& textureObject, + Bool imageBindableStorageRequired) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): this sync still arrives holding the FRONTEND object + // (the object-class rows) and resolves its twin by frontend identity - the + // frontend-keyed registry P3b/P4b rekeys onto handles. The probe (and the mint on + // a first sync) is named debt inside the scope, not an unwrapped violation. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + auto* backendTextureSlot = g_backendTextureObjects.Find(textureObject.get()); + auto& backendSlot = backendTextureSlot ? *backendTextureSlot + : g_backendTextureObjects.GetOrCreate(textureObject); + if (!backendSlot) { + backendSlot = MakeShared(); + } + + // A by-VALUE copy of the twin for the duration of the syncs below. `backendSlot` is a + // reference INTO the open-addressed registry, and syncing can RE-ENTER this function: + // a texture created by glTextureView has to sync the texture whose storage it views + // first (SyncTextureViewToBackend), and that nested call may insert, grow the map and + // relocate every entry - leaving the reference dangling. Holding the object itself + // keeps the calls below working on the right twin regardless; the slot is re-resolved + // at the end for the reference this function returns. + // + // The slot-table arm has no such hazard - an entry is an array element and a nested + // insert can only reallocate the vector, which the re-resolve at the tail already + // handles - so the copy is a refcount it does not need to pay. It keeps the copy for + // exactly one thing: holding the twin alive across the nested sync. + const SharedPtr backendObj = backendSlot; + + if (imageBindableStorageRequired) { + backendObj->RequireImageBindableStorage(textureObject); + } + backendObj->SyncTextureParamsToBackend(textureObject); + backendObj->SyncBuiltinSamplerToBackend(textureObject); + backendObj->SyncMipmapsToBackend(textureObject); + // The storage sync may RE-MINT the driver texture - a fresh glTexStorage after a + // shape change, an image-bindable widening, or the glTextureView that an + // ARB_texture_view view is created on - which discards every parameter the two calls + // above just pushed. Re-push them here rather than leaving it to the next sync: the + // very next thing that happens is usually the draw this sync was run for, and until + // the filters land the new texture is at the ES defaults, which for a single-level or + // integer texture is not merely mis-filtered but INCOMPLETE, i.e. it samples zero. if (backendObj->NeedsParameterResync()) { backendObj->SyncTextureParamsToBackend(textureObject); backendObj->SyncBuiltinSamplerToBackend(textureObject); } +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // One re-resolve, and only because a nested GetOrCreate may have GROWN the + // vector and moved the element; the entry itself cannot have been erased, since + // nothing on this arm erases a live slot. No second Find-or-create, no + // put-the-twin-back repair. + auto* slot = g_backendTextureObjects.Find(textureObject.get()); + MOBILEGL_ASSERT(slot != nullptr && *slot != nullptr, + "the texture twin resolved at entry is gone after its own sync"); + if (slot != nullptr && *slot != nullptr) { + return *slot; + } + // Cannot happen - the caller holds the frontend object, so its slot cannot be + // reclaimed underneath this call - but the return is a reference, and a null + // deref in a release build is a worse way to learn that than a re-created twin. + auto& repaired = g_backendTextureObjects.GetOrCreate(textureObject); + if (!repaired) { + repaired = backendObj; + } + return repaired; + } +#endif auto* refreshedSlot = g_backendTextureObjects.Find(textureObject.get()); auto& refreshedBackendObj = refreshedSlot ? *refreshedSlot : g_backendTextureObjects.GetOrCreate(textureObject); @@ -1360,6 +2628,108 @@ namespace MobileGL::MG_Backend::DirectGLES { // * everything unit bindings say nothing about: the touched-unit high-water mark, // the frontend context identity, the backend ES context generation, and (for the // resolution memo) the program keys that arbitrate aliased targets. +#if MOBILEGL_PIPE_PUSH + // P2: the snapshot stops holding weak_ptrs and holds the frontend objects' LIFETIME IDs. + // The weak_ptr was here for one reason - a raw address lies once the allocator recycles + // it - and a lifetime id is a monotone per-class counter that is never handed out twice, + // so it answers the same question with an integer compare and without pinning a control + // block. 0 means "nothing bound", which no live object can collide with (the counters + // start at 1). This is not the twin table's {slot, gen}: a bound texture that has never + // been synced has no twin and therefore no handle, so a handle-keyed snapshot would read + // two never-synced textures as equal. The identity has to exist before the twin does. + // + // Split by the RUNTIME arm, not by the build, for exactly the reason g_fbSlotCache is: + // MOBILEGL_PIPE_PUSH=0 has to reproduce P1's behaviour (ConfigLoader.cpp), and a + // legacy-arm run that debounced on lifetime ids would be running P2's mechanism while + // the A/B attributed the result to P1. The two answers are equivalent - OwnerEquals on + // two empty pointers is true and LifetimeIdOf(nullptr) == 0 == 0; a live-versus-expired + // control block and two distinct lifetime ids both compare unequal - so keeping the + // legacy fields costs that arm nothing but the words, and gives the control back its + // fidelity. A build with no legacy arm compiled carries neither the fields nor the + // branch. + struct UnitBindingsSnapshot { + Array slotObjects{}; + Uint64 samplerObject = 0; +#if MOBILEGL_PIPE_LEGACY_MEMOS + // P1's identity, kept verbatim for the legacy arm only. + Array, (SizeT)TextureTarget::TextureTargetCount> + legacySlotObjects{}; + WeakPtr legacySamplerObject{}; +#endif + }; + + static Uint64 LifetimeIdOf(const SharedPtr& object) { + return object ? object->GetLifetimeId() : 0; + } + + static Uint64 LifetimeIdOf(const SharedPtr& object) { + return object ? object->GetLifetimeId() : 0; + } + +#if MOBILEGL_PIPE_LEGACY_MEMOS +#define MGB_UNIT_BINDINGS_HANDLE_ARM (EsprytSlotTablesEnabled()) +#else +#define MGB_UNIT_BINDINGS_HANDLE_ARM (true) +#endif + + static void CaptureUnitBindings(Int maxTouchedUnit, Vector& out) { + const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM; + out.resize(static_cast(maxTouchedUnit + 1)); + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); + auto& snapshot = out[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + if (handleArm) { + snapshot.slotObjects[i] = LifetimeIdOf(slots[i].GetBoundObject()); + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + else { + snapshot.legacySlotObjects[i] = slots[i].GetBoundObject(); + } +#endif + } + if (handleArm) { + snapshot.samplerObject = LifetimeIdOf(textureUnit.GetSamplerObject()); + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + else { + snapshot.legacySamplerObject = textureUnit.GetSamplerObject(); + } +#endif + } + } + + static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector& snapshots) { + if (snapshots.size() != static_cast(maxTouchedUnit + 1)) return false; + const Bool handleArm = MGB_UNIT_BINDINGS_HANDLE_ARM; + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); + const auto& snapshot = snapshots[static_cast(unit)]; + const auto& slots = textureUnit.GetAllBindingSlots(); + for (SizeT i = 0; i < slots.size(); ++i) { + if (handleArm) { + if (snapshot.slotObjects[i] != LifetimeIdOf(slots[i].GetBoundObject())) return false; + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + else if (!OwnerEquals(snapshot.legacySlotObjects[i], slots[i].GetBoundObject())) { + return false; + } +#endif + } + if (handleArm) { + if (snapshot.samplerObject != LifetimeIdOf(textureUnit.GetSamplerObject())) return false; + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + else if (!OwnerEquals(snapshot.legacySamplerObject, textureUnit.GetSamplerObject())) { + return false; + } +#endif + } + return true; + } +#undef MGB_UNIT_BINDINGS_HANDLE_ARM +#else struct UnitBindingsSnapshot { Array, (SizeT)TextureTarget::TextureTargetCount> slotObjects{}; @@ -1369,7 +2739,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static void CaptureUnitBindings(Int maxTouchedUnit, Vector& out) { out.resize(static_cast(maxTouchedUnit + 1)); for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); auto& snapshot = out[static_cast(unit)]; const auto& slots = textureUnit.GetAllBindingSlots(); for (SizeT i = 0; i < slots.size(); ++i) { @@ -1382,7 +2752,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool UnitBindingsUnchanged(Int maxTouchedUnit, const Vector& snapshots) { if (snapshots.size() != static_cast(maxTouchedUnit + 1)) return false; for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const auto& snapshot = snapshots[static_cast(unit)]; const auto& slots = textureUnit.GetAllBindingSlots(); for (SizeT i = 0; i < slots.size(); ++i) { @@ -1392,6 +2762,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } return true; } +#endif static Vector g_observedUnitBindings; static Uint64 g_observedUnitBindingsContextId = 0; @@ -1409,13 +2780,140 @@ namespace MobileGL::MG_Backend::DirectGLES { // a context switch, a high-water-mark move or a bindings change each recapture and // bump - so epoch equality alone proves the bindings a consumer resolved against // are the bindings on the units now. +#if MOBILEGL_PIPE_PUSH + // P4a e2 (D-G1). THE UNIT-BINDINGS EPOCH DERIVATION, REPLACED BY THE RECORDS' SERIALS. + // + // CurrentUnitBindingsEpoch below exists to answer one question - "did WHAT the touched + // units hold change?" - without re-deriving on a redundant re-bind, and it pays for the + // answer with a per-unit snapshot walk over every binding slot plus the unit's sampler + // object, run whenever the frontend's texture bind generation moves (26.2 moves it + // around every texture-unit switch). The applier already holds the answer: + // set_sampler_views and bind_sampler_states go out through a ContentHash suppressor, + // so their serials move IF AND ONLY IF the resolved per-unit view set or the per-unit + // sampler set really moved. Two Uint64 reads replace the walk, and the suppressor is + // what makes that sound - the hash is over every entry of each tail, so suppression + // cannot hide a change. + // + // BOTH serials, because the snapshot covered both halves: slotObjects is the view set + // and samplerObject is the sampler-state set. + // + // DECLINES until both windows have arrived at least once, and the COUNT is what says + // so rather than the serial: MGPipeApplierReset advances every P4a working-state + // serial whether or not anything was ever emitted, so a serial is never "no set has + // been received". A declined answer leaves the caller on the snapshot walk, which is + // the honest thing for a tree whose client half has not landed. + // + // The two arms' epoch values share one number space during the transition (a process + // can run the walk before the first emission and the serials after it). That is safe + // by construction: consumers only ever ask "is this the same value I stamped", the + // walk's counter is small and dense and this one is a 64-bit mix, so the two cannot + // meet except with vanishing probability - and a collision costs a spare rebuild. + static Bool UnitBindingsEpochFromRecords(Uint64& out, Int maxTouchedUnit) { + const auto& st = MG_Pipe::MGPipeApplier(); + // P4a decline-site T2: S - FLIPPED AT THE VERIFICATION ROUND to loud-once, then + // the decline, and MEASURED before it was believed. Bits 10 and 11 are on (the + // caller asked SamplerSubsystemEnabled(), which refuses one without the other) + // and package C has landed, so a draw that reaches here with NEITHER window ever + // received looks like a seam defect - but the first real-path run said otherwise + // for one whole class of draw and the condition is narrowed by that measurement, + // not by argument. + // + // maxTouchedUnit < 0 IS "THIS DRAW TOUCHES NO TEXTURE UNIT AT ALL", and for such + // a draw an empty sampler-view and sampler-state window is the correct and only + // possible emission: there is nothing to describe. 161 of the integration lane's + // 477 processes are that shape, every one of them at units 0..-1, and a line that + // fires on a third of a green lane is a line nobody reads. So the refusal is + // scoped to a draw that actually touches a unit, where an empty window really + // does mean no set_sampler_views and no bind_sampler_states ever arrived. + // + // It declines to the snapshot walk rather than refusing the draw, because the + // walk answers the same question correctly and a wrong picture is not the failure + // mode here - a silent permanent fallback is, and that is what the line is for. + // The caller's MINOR-4 gate tick still counts EVERY decline, loud or not. + if (st.SamplerViewCount == 0 && st.SamplerStateCount == 0) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.3: UNDER A LIVE WIRE THE DECLINE IS A REFUSAL. + // + // The fall-back below is the pre-handle snapshot walk over GetTextureUnitObject, + // and on a run-ahead server that walk reads client memory for a record that has + // already been answered - there is no wait left in which the answer could be + // right. Ruling 19 makes the missing window unrepresentable at the sink; this is + // the same statement one level down, for the backend that would have papered over + // it. Under monolith the fall-back is correct and stays. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && maxTouchedUnit >= 0) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"SetSamplerViews.Count\"} - a draw " + "touches units 0..%d and neither a sampler-view nor a sampler-state " + "window has ever been applied while the sampler subsystem bit is set; " + "the pre-handle unit-bindings snapshot walk reads client memory and is " + "refused under an active transport (CONTRACT-P5E.md §5.3)", + static_cast(maxTouchedUnit)); + std::abort(); + } +#endif + if (maxTouchedUnit >= 0) { + MGLOG_E_ONCE("A sampler record does not describe the binding it names: a draw " + "touches units 0..%d and neither a sampler-view nor a sampler-state " + "window has ever been applied while the sampler subsystem bit is " + "set; running the pre-handle unit-bindings snapshot walk.", + static_cast(maxTouchedUnit)); + } + return false; + } + // Local, because the tracker's MGPipeMixShutter lives in MG_Impl and no backend + // translation unit may reach for it. + const Uint64 mixed = + (st.SamplerViewsSerial * 0x9E3779B97F4A7C15ull) ^ (st.SamplerStatesSerial + 0x165667B19E3779F9ull); + out = mixed; + return true; + } +#endif + static Uint64 CurrentUnitBindingsEpoch(Int maxTouchedUnit) { - const Uint64 contextId = MG_State::pGLContext->GetTextureContextId(); - const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); +#if MOBILEGL_PIPE_PUSH + // P4a decline-site T1: M - the mask says this family is not switched on, and it + // IS D's SamplerSubsystemEnabled() now. Silent, confirmed at the verification + // round: the mask's word is the one thing a decline may be quiet about. + if (SamplerSubsystemEnabled()) { + Uint64 epochFromRecords = 0; + if (UnitBindingsEpochFromRecords(epochFromRecords, maxTouchedUnit)) { + // No accessor reads and no walk on this arm; the two PipeStats accessor + // ticks below are not counted because no accessor was called. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytUnitBindingsEpoch, + /*hit=*/true); + } + return epochFromRecords; + } + // MINOR-4. THE RECORD ARM'S DECLINE IS TICKED AS A MISS, and it has to be: + // the walk arm below ticks the SAME gate with its own hit/miss, so without + // this a fully declining tree reports the walk memo's high hit rate on + // Gate::EsprytUnitBindingsEpoch and the verification round's "hit rate goes to + // 100%" cannot tell "the record arm engaged" from "the walk arm's memo hit". + // With it, every declining call contributes at least one miss, so 100% means + // exactly one thing. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytUnitBindingsEpoch, + /*hit=*/false); + } + } +#endif + const Uint64 contextId = MGB_CTX->GetTextureContextId(); + const Uint64 bindGeneration = MGB_CTX->GetTextureBindGeneration(); + if (MG_Util::PipeStats::Enabled()) { + // Two accessor calls whichever way the shutter goes; only the unit WALK is + // gated, and that walk reads no GLContext accessor of its own. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 2); + } if (g_observedUnitBindingsContextId == contextId && g_observedUnitBindingsMaxUnit == maxTouchedUnit && g_observedUnitBindingsGeneration == bindGeneration) { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytUnitBindingsEpoch, /*hit=*/true); + } return g_unitBindingsEpoch; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytUnitBindingsEpoch, /*hit=*/false); + } if (g_observedUnitBindingsContextId != contextId || g_observedUnitBindingsMaxUnit != maxTouchedUnit || !UnitBindingsUnchanged(maxTouchedUnit, g_observedUnitBindings)) { CaptureUnitBindings(maxTouchedUnit, g_observedUnitBindings); @@ -1466,6 +2964,49 @@ namespace MobileGL::MG_Backend::DirectGLES { } return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.8 / ruling 1: THE ARM SELECTOR for every by-handle site in + // this family. Under ANY active transport (lockstep or run-ahead, so RUN_AHEAD=0 stays a + // pure wait-rule A/B on identical server code), and only with BOTH family bits set: a + // unit's texture comes from the sampler-view window and its storage from the + // texture-resource record, and half of each is not an arm. The push-monolith build keeps + // the frontend walk token for token - there is no server to answer from there. + static Bool UnitTexturesByHandle() { + return MG_Config::Transport != MG_Config::TransportMode::Monolith && SamplerSubsystemEnabled() && + TextureResourceSubsystemEnabled(); + } + + // P5e (tx2), CONTRACT-P5E §5.2: THE UNIT WORK LIST WITH NO BORROWED SLOT IN IT. + // + // The pre-P5e entry borrows a pointer INTO a frontend binding slot and remembers which + // frontend object it was paired with, and PairingsIntact re-checks that pairing before + // every replay. Both exist because the list's key is derived state and a silent slot swap + // could leave a stale pairing driving texture A's twin from texture B's frontend state. + // + // A HANDLE HAS NO SUCH HAZARD. `Res` is {slot, gen}: a recycled slot arrives at a higher + // Gen, which the twin table's forward-Gen rule answers by RESETTING the twin, and a + // backward Gen is refused outright - so there is nothing for a pairing check to catch and + // PairingsIntact is not carried on this list at all. The key is the applier's own + // (ContextSerial, SamplerViewsSerial, window) plus the backend context generation, and + // SamplerViewsSerial moves IFF the resolved per-unit view set moved, which the emitter's + // content-hash suppressor is what makes sound. + // + // Its OWN list and its own key rather than a re-shape of UnitTextureSyncEntry, because + // that struct is also the draw-FBO and read-FBO attachment lists' (fb's), and one name + // carrying two shapes across two packages' worktrees is the merge trap the contract + // package exists to avoid. + struct UnitTextureSyncByHandleEntry { + MG_Pipe::MGPipeHandle Res = MG_Pipe::kMGPipeNullHandle; + BackendTextureObject* backend = nullptr; + }; + static Vector g_unitTextureSyncListByHandle; + static Bool g_unitTextureSyncListByHandleValid = false; + static Uint64 g_unitTextureSyncListByHandleContextSerial = 0; + static Uint64 g_unitTextureSyncListByHandleViewsSerial = 0; + static Uint32 g_unitTextureSyncListByHandleStart = 0; + static Uint32 g_unitTextureSyncListByHandleCount = 0; + static Uint g_unitTextureSyncListByHandleContextGeneration = 0; +#endif static Vector g_unitTextureSyncList; static Bool g_unitTextureSyncListValid = false; static Uint64 g_unitTextureSyncListContextId = 0; @@ -1474,6 +3015,31 @@ namespace MobileGL::MG_Backend::DirectGLES { static Uint64 g_unitTextureSyncListEpoch = 0; static Uint64 g_unitTextureSyncListSamplingGeneration = 0; +#if MOBILEGL_PIPE_PUSH + // P5e (fb, CONTRACT-P5E.md §5.4): THE TWO ATTACHMENT LISTS GET AN ENTRY OF THEIR OWN, + // and it borrows nothing. + // + // The unit list's entry borrows the binding slot's SharedPtr and re-checks the pairing + // before every replay, because its key (the unit-bindings epoch) is derived state that + // a silent slot swap could get past. An attachment list under the record arm has + // neither problem and cannot afford the borrow: the key IS the record - {Fbo, + // ContentHash} - and every attachment edit moves the hash by construction, so the + // pairing has nothing left to catch; and the borrowed pointer was into a frontend + // FramebufferObject, which is exactly the thing this package stops holding. So an entry + // is the texture HANDLE the record named plus the twin it resolved to, PairingsIntact + // becomes `entry.Res == surface.Res` folded into the rebuild, and the list owns no + // reference to anything the client can free. + // + // The twin pointer is still a BORROW, on the unit list's own terms: the registry keeps + // a twin alive until its object dies, an attached texture cannot die while the record + // that names it stands, and the very next thing done with the entry is a by-handle sync + // that would re-resolve it anyway. + struct FboAttachmentSyncEntry { + MG_Pipe::MGPipeHandle Res = MG_Pipe::kMGPipeNullHandle; + BackendTextureObject* backend = nullptr; + }; +#endif + // Sibling memo for the draw FBO's texture attachments (see the use site in // SyncNeccessaryTextures for the key derivation and the borrow rules, which are the // unit list's). A null FBO pointer means "not stamped". @@ -1483,6 +3049,74 @@ namespace MobileGL::MG_Backend::DirectGLES { static Uint16 g_fboTextureSyncListObjectVersion = 0; static Uint64 g_fboTextureSyncListContextId = 0; static Uint g_fboTextureSyncListContextGeneration = 0; +#if MOBILEGL_PIPE_PUSH + // P4a e2. The record-keyed half of the same memo. The draw framebuffer record's + // ContentHash replaces the two VERSION halves of the key - the hash is taken over + // every field the record carries, so an attachment edit always moves it, and Fbo is + // inside it, so a recycled framebuffer handle cannot be suppressed against its + // predecessor. THE IDENTITY HALF IS STILL A POINTER COMPARE and deliberately stays + // one: this list borrows the attachment's own SharedPtr slots, so what it needs to + // know is that the frontend object it borrowed from is the one bound now, and that + // question has no handle in it until package D re-keys the twin tables. Held in its + // own fields with its own flag rather than reusing the versions', because a process + // runs the frontend key before the client's first emission and the record key after + // it, and one field carrying two key shapes is how a stale half gets compared against + // a live one. + static Uint64 g_fboTextureSyncListContentHash = 0; + static Bool g_fboTextureSyncListRecordKeyed = false; + // P5e (fb): the record-keyed list itself, held apart from the pre-handle one for the + // reason the two key shapes are held apart - one field carrying two shapes is how a + // stale half gets compared against a live one. Its key is + // {Fbo, ContentHash, ContextSerial, g_backendContextGeneration}: the applier's + // ContextSerial replaces GetTextureContextId (a frontend read the record arm may not + // take), and the two version halves are gone because the hash covers them. + static Vector g_fboAttachmentSyncList; + static MG_Pipe::MGPipeHandle g_fboAttachmentSyncListFbo = MG_Pipe::kMGPipeNullHandle; + static Uint64 g_fboAttachmentSyncListContentHash = 0; + static Uint64 g_fboAttachmentSyncListContextSerial = 0; + static Uint g_fboAttachmentSyncListContextGeneration = 0; + static Bool g_fboAttachmentSyncListValid = false; + + // The read framebuffer's, same shape and same reasons. Its own list because it is + // keyed on a different framebuffer and deduped against the draw one. + static Vector g_readFboAttachmentSyncList; + static MG_Pipe::MGPipeHandle g_readFboAttachmentSyncListFbo = MG_Pipe::kMGPipeNullHandle; + static Uint64 g_readFboAttachmentSyncListContentHash = 0; + static Uint64 g_readFboAttachmentSyncListContextSerial = 0; + static Uint g_readFboAttachmentSyncListContextGeneration = 0; + static Bool g_readFboAttachmentSyncListValid = false; + + // Rebuild-or-replay, shared by both lists. The record's texture surfaces ARE the + // membership (§5.4); a renderbuffer-only framebuffer - the common Minecraft frame - + // reduces to the key compare and an empty loop. + // + // ONE CALL PER ENTRY, and it is tx2's by-handle seam rather than the three the + // pre-handle arm makes: SyncTextureToBackendByHandle is the by-handle twin of + // SyncTextureObjectToBackend, which is what the REBUILD has always called here, so the + // params / builtin-sampler / mipmap work and its own clean gate are inside it. Calling + // the three separately would need by-handle forms of the first two that nothing + // declares, and would put this file's copy of the clean gate beside tx2's. + static void SyncRecordAttachmentTextures(const MG_Pipe::MGPFramebufferState& record, + Vector& list, Bool listValid) { + if (listValid) { + for (const auto& entry : list) { + TextureImpl::SyncTextureToBackendByHandle(entry.Res, /*imageBindableStorageRequired=*/false); + } + return; + } + list.clear(); + const auto add = [&](const MG_Pipe::MGPSurface& surface) { + if (surface.Kind != MG_Pipe::kMGPipeSurfaceKindTexture) return; + if (MG_Pipe::MGPipeHandleIsNull(surface.Res)) return; + auto& twin = TextureImpl::SyncTextureToBackendByHandle(surface.Res, + /*imageBindableStorageRequired=*/false); + list.push_back({surface.Res, twin.get()}); + }; + for (Uint i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) add(record.Color[i]); + add(record.Depth); + add(record.Stencil); + } +#endif // The frontend texture-state keys the per-draw texture stages // (SyncNeccessaryTextures, then BindCurrentTextures) both consume. Captured @@ -1500,14 +3134,205 @@ namespace MobileGL::MG_Backend::DirectGLES { DrawTextureSyncKeys CaptureDrawTextureSyncKeys() { DrawTextureSyncKeys keys; - keys.contextId = MG_State::pGLContext->GetTextureContextId(); + keys.contextId = MGB_CTX->GetTextureContextId(); // Units past the frontend's high-water mark have provably-empty slots. - keys.maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit(); - keys.samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + // + // P4a e2 DELIBERATELY DOES NOT take this off the sampler-view window's Count, and + // the reason is asymmetric risk. D-G2 makes Count the client's spelling of exactly + // this number, so the substitution looks free - but the two failure directions are + // not alike: a window that is larger than the frontend's mark costs a walk over + // provably-empty units, while one that is SMALLER silently drops the sync of a + // texture a draw is about to sample, which no gate on this tree can see because + // this tree emits no windows at all. The design assigns the high-water mark to the + // CLIENT as the count argument and says nothing about re-deriving it server-side. + // It is one accessor read per draw; the integrator can move it in one line once a + // tree exists where the two can be compared. + keys.maxTouchedUnit = MGB_CTX->GetMaxTouchedTextureUnit(); + keys.samplingGeneration = MGB_CTX->GetSamplingResolutionGeneration(); keys.unitBindingsEpoch = CurrentUnitBindingsEpoch(keys.maxTouchedUnit); + if (MG_Util::PipeStats::Enabled()) { + // The three reads above; CurrentUnitBindingsEpoch counts its own two when it + // takes the walk arm and none when the records answered. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); + } return keys; } +#if MOBILEGL_PIPE_PUSH + // P4a e2 (D-E3). The READ framebuffer's texture attachments, and why this exists. + // + // A texture that is an attachment of the READ framebuffer ONLY reaches + // SyncAttachmentObject, which calls SyncMipmapsToBackend AND NOTHING ELSE - so its + // swizzle, its GL_DEPTH_STENCIL_TEXTURE_MODE, its LOD clamp and its border colour + // never reach the driver. The list that pushes texture PARAMETERS for an attachment is + // the one below, and it reads the DRAW binding slot alone. That is the gap + // ARCHITECTURE.md's D10 names, and the roadmap makes closing it a mandatory, + // red-before deliverable of this phase: set_texture_params is addressed by RESOURCE + // and is independent of any binding, so a texture's parameters exist the moment they + // move and an attachment - draw or read - gets them. + // + // DELIBERATELY NOT GATED ON THE FRAMEBUFFER SUBSYSTEM BIT. It is a deliverable of the + // phase rather than an arm of it, so both arms of the object A/B carry it and the A/B + // keeps measuring the arm rather than the arm plus a behaviour change. It is inside + // MOBILEGL_PIPE_PUSH, so the pull build is untouched. + // + // Its own list and its own key rather than an extension of the draw list's, because + // the draw list is keyed on the draw binding and its pull-build copy must stay + // textually what it was. + static Vector g_readFboTextureSyncList; + static MG_State::GLState::FramebufferObject* g_readFboTextureSyncListFbo = nullptr; + static Uint16 g_readFboTextureSyncListSlotVersion = 0; + static Uint16 g_readFboTextureSyncListObjectVersion = 0; + static Uint64 g_readFboTextureSyncListContextId = 0; + static Uint g_readFboTextureSyncListContextGeneration = 0; + static Uint64 g_readFboTextureSyncListContentHash = 0; + static Bool g_readFboTextureSyncListRecordKeyed = false; + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, CONTRACT-P5E.md §5.4): THE RECORD ARM OF BOTH ATTACHMENT LISTS, and it is + // the package's core - this is the one unconditional per-draw live read the framebuffer + // family had, taken on every draw whether the memo hit or missed. + // + // Three frontend reads go in one move: the binding slot (twice), the FramebufferObject + // the entries borrowed their attachment slots from, and GetTextureContextId. The key is + // {Fbo, ContentHash, ContextSerial, g_backendContextGeneration} - the record's own hash + // in place of the slot/object version pair (it covers every surface, and Fbo is inside + // it, so a recycled framebuffer cannot be suppressed against its predecessor), the + // APPLIER's ContextSerial in place of the frontend context id, and the backend context + // generation unchanged because it answers a question about driver names that no + // client-side value can answer. + // + // The two lists are still two, and the dedupe is still "one object on both bindings" - + // asked of the two BOUND HANDLES rather than of two pointers, which is the same + // question with a spelling a recycled slot cannot fool. + static void SyncFramebufferAttachmentTexturesByRecord() { + const auto& st = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPipeHandle drawHandle = + st.BoundFramebuffer[SizeT(MG_Pipe::MGPipeFramebufferTarget::Draw)]; + const MG_Pipe::MGPipeHandle readHandle = + st.BoundFramebuffer[SizeT(MG_Pipe::MGPipeFramebufferTarget::Read)]; + const MG_Pipe::MGPFramebufferState* const drawRecord = + BoundFramebufferRecord(FramebufferTarget::Draw); + const MG_Pipe::MGPFramebufferState* const readRecord = + BoundFramebufferRecord(FramebufferTarget::Read); + const Uint64 contextSerial = st.ContextSerial; + + if (drawRecord == nullptr) { + // Not a decline this function can refuse on: SyncCurrentFBO runs before every + // caller of this one and is where the missing record is named and refused + // (RefuseFramebufferBindingSlotRead). Here it only means "nothing to walk". + g_fboAttachmentSyncListValid = false; + g_fboAttachmentSyncList.clear(); + } else { + const Bool listValid = g_fboAttachmentSyncListValid && + g_fboAttachmentSyncListFbo == drawRecord->Fbo && + g_fboAttachmentSyncListContentHash == drawRecord->ContentHash && + g_fboAttachmentSyncListContextSerial == contextSerial && + g_fboAttachmentSyncListContextGeneration == g_backendContextGeneration; + SyncRecordAttachmentTextures(*drawRecord, g_fboAttachmentSyncList, listValid); + g_fboAttachmentSyncListFbo = drawRecord->Fbo; + g_fboAttachmentSyncListContentHash = drawRecord->ContentHash; + g_fboAttachmentSyncListContextSerial = contextSerial; + g_fboAttachmentSyncListContextGeneration = g_backendContextGeneration; + g_fboAttachmentSyncListValid = true; + } + + if (readRecord == nullptr || readHandle == drawHandle) { + g_readFboAttachmentSyncListValid = false; + g_readFboAttachmentSyncList.clear(); + return; + } + const Bool readListValid = g_readFboAttachmentSyncListValid && + g_readFboAttachmentSyncListFbo == readRecord->Fbo && + g_readFboAttachmentSyncListContentHash == readRecord->ContentHash && + g_readFboAttachmentSyncListContextSerial == contextSerial && + g_readFboAttachmentSyncListContextGeneration == g_backendContextGeneration; + SyncRecordAttachmentTextures(*readRecord, g_readFboAttachmentSyncList, readListValid); + g_readFboAttachmentSyncListFbo = readRecord->Fbo; + g_readFboAttachmentSyncListContentHash = readRecord->ContentHash; + g_readFboAttachmentSyncListContextSerial = contextSerial; + g_readFboAttachmentSyncListContextGeneration = g_backendContextGeneration; + g_readFboAttachmentSyncListValid = true; + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + + static void SyncReadFramebufferTextureAttachments(const DrawTextureSyncKeys& keys, + const MG_State::GLState::FramebufferObject* drawFbo) { + const auto& readSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Read); + const auto& readFBO = readSlot.GetBoundObject(); + // One object bound to both bindings has already been walked as the draw + // framebuffer, and its attachments are already in the draw list; a pointer compare + // is the whole dedupe, because a framebuffer has two or three attachments and any + // set would cost more than the syncs it saves. + if (!readFBO || readFBO.get() == drawFbo) { + g_readFboTextureSyncListFbo = nullptr; + g_readFboTextureSyncList.clear(); + return; + } + + const Uint16 slotVersion = readSlot.GetVersion(); + const Uint16 objectVersion = readFBO->GetObjectVersion(); + + Bool recordKeyed = false; + Uint64 contentHash = 0; + // P4a decline-site F8 (read list): K - not a behavioural decline but a memo-KEY + // selection. Both key shapes are correct and are held in separate fields, so a + // process that runs the version key before the first emission and the ContentHash + // key after it never compares one against the other. NO FLIP TAKEN at the + // verification round; the null check is wire v3's pointer accessor, not a new + // decline. + if (FramebufferSubsystemEnabled()) { + const auto* record = BoundFramebufferRecord(FramebufferTarget::Read); + if (record != nullptr && !MG_Pipe::MGPipeHandleIsNull(record->Fbo)) { + recordKeyed = true; + contentHash = record->ContentHash; + } + } + + const Bool listValid = + g_readFboTextureSyncListFbo == readFBO.get() && + g_readFboTextureSyncListRecordKeyed == recordKeyed && + (recordKeyed ? g_readFboTextureSyncListContentHash == contentHash + : (g_readFboTextureSyncListSlotVersion == slotVersion && + g_readFboTextureSyncListObjectVersion == objectVersion)) && + g_readFboTextureSyncListContextId == keys.contextId && + g_readFboTextureSyncListContextGeneration == g_backendContextGeneration && + PairingsIntact(g_readFboTextureSyncList); + + if (listValid) { + for (const auto& entry : g_readFboTextureSyncList) { + // The same aggregate gate the two lists above use. + if (entry.backend->IsDrawSyncClean(entry.slot->get(), keys.contextId, + keys.samplingGeneration)) { + continue; + } + entry.backend->SyncTextureParamsToBackend(*entry.slot); + entry.backend->SyncBuiltinSamplerToBackend(*entry.slot); + entry.backend->SyncMipmapsToBackend(*entry.slot); + } + return; + } + + g_readFboTextureSyncListFbo = nullptr; + g_readFboTextureSyncList.clear(); + for (const auto& attachment : readFBO->GetAllAttachmentObjects()) { + if (!attachment.IsTexture()) continue; + auto& textureObject = attachment.GetTexture(); + if (textureObject) { + g_readFboTextureSyncList.push_back({&textureObject, textureObject.get(), + SyncTextureObjectToBackend(textureObject).get()}); + } + } + g_readFboTextureSyncListFbo = readFBO.get(); + g_readFboTextureSyncListSlotVersion = slotVersion; + g_readFboTextureSyncListObjectVersion = objectVersion; + g_readFboTextureSyncListContextId = keys.contextId; + g_readFboTextureSyncListContextGeneration = g_backendContextGeneration; + g_readFboTextureSyncListContentHash = contentHash; + g_readFboTextureSyncListRecordKeyed = recordKeyed; + } +#endif // MOBILEGL_PIPE_PUSH + void SyncNeccessaryTextures(const DrawTextureSyncKeys& keys) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); @@ -1522,6 +3347,68 @@ namespace MobileGL::MG_Backend::DirectGLES { const Int maxTouchedUnit = keys.maxTouchedUnit; const Uint64 samplingGeneration = keys.samplingGeneration; const Uint64 unitBindingsEpoch = keys.unitBindingsEpoch; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.2. THE UNIT HALF, BY HANDLE. + // + // set_sampler_views is the RESOLVED answer to the question this walk asks - which + // texture does each touched unit sample - and the client has already applied every + // drop the walk below performs by hand: an image-less default texture and a texture + // that samples as incomplete are both null in the record (SamplerEmit.h's two drops), + // and the alias contest between two real textures on one native target was arbitrated + // by the program's own sampler types. So the window IS the work list. + // + // NAMED BEHAVIOUR DELTA (P5e-5, contract §5.2): this syncs the PROGRAM-RESOLVED + // texture per unit, where the frontend walk syncs every slot of every touched unit. + // Coverage is unchanged - views UNION image units UNION the FBO attachment lists + // UNION the waited texture ops still cover every texture anything reads - so it + // narrows WORK and not what reaches the driver. + if (UnitTexturesByHandle()) { + const auto& st = MG_Pipe::MGPipeApplier(); + const Bool listValid = g_unitTextureSyncListByHandleValid && + g_unitTextureSyncListByHandleContextSerial == st.ContextSerial && + g_unitTextureSyncListByHandleViewsSerial == st.SamplerViewsSerial && + g_unitTextureSyncListByHandleStart == st.SamplerViewStart && + g_unitTextureSyncListByHandleCount == st.SamplerViewCount && + g_unitTextureSyncListByHandleContextGeneration == g_backendContextGeneration; + if (listValid) { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytTextureSyncList, /*hit=*/true); + } + for (const auto& entry : g_unitTextureSyncListByHandle) { + const auto* record = PipeTextureRecordForHandle(entry.Res); + if (record == nullptr) continue; + // The aggregate gate, from the twin's own serials against the record's - + // no frontend version, no context id, no sampling generation. + if (entry.backend->IsDrawSyncCleanByRecord(entry.Res, *record)) continue; + SyncTextureToBackendByHandle(entry.Res); + } + } else { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytTextureSyncList, /*hit=*/false); + } + g_unitTextureSyncListByHandleValid = false; + g_unitTextureSyncListByHandle.clear(); + const Uint32 windowEnd = st.SamplerViewStart + st.SamplerViewCount; + for (Uint32 index = st.SamplerViewStart; + index < windowEnd && index < st.BoundSamplerViews.size(); ++index) { + const auto& view = st.BoundSamplerViews[index]; + if (MG_Pipe::MGPipeHandleIsNull(view.Texture)) continue; + auto& twin = SyncTextureToBackendByHandle(view.Texture); + if (!twin) continue; + g_unitTextureSyncListByHandle.push_back({view.Texture, twin.get()}); + } + g_unitTextureSyncListByHandleContextSerial = st.ContextSerial; + g_unitTextureSyncListByHandleViewsSerial = st.SamplerViewsSerial; + g_unitTextureSyncListByHandleStart = st.SamplerViewStart; + g_unitTextureSyncListByHandleCount = st.SamplerViewCount; + g_unitTextureSyncListByHandleContextGeneration = g_backendContextGeneration; + g_unitTextureSyncListByHandleValid = true; + } + // The pre-handle list must not be replayed after this arm ran: its entries borrow + // frontend slots this arm never refreshed. + g_unitTextureSyncListValid = false; + } else +#endif // The epoch survives redundant re-binds; the sampling-resolution generation // covers the one membership input the epoch cannot see - a default texture's // image appearing or vanishing flips IsUndefinedDefaultTexture with no binding @@ -1534,6 +3421,11 @@ namespace MobileGL::MG_Backend::DirectGLES { g_unitTextureSyncListEpoch == unitBindingsEpoch && g_unitTextureSyncListSamplingGeneration == samplingGeneration && PairingsIntact(g_unitTextureSyncList)) { + if (MG_Util::PipeStats::Enabled()) { + // Gate 2 of section 2.3.1. The served path walks the memoised entries + // and reads no GLContext accessor at all. + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytTextureSyncList, /*hit=*/true); + } for (const auto& entry : g_unitTextureSyncList) { // Aggregate gate == the conjunction of the three callees' own // early-outs (see IsDrawSyncClean); skipping on true is @@ -1546,10 +3438,17 @@ namespace MobileGL::MG_Backend::DirectGLES { entry.backend->SyncMipmapsToBackend(*entry.slot); } } else { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytTextureSyncList, /*hit=*/false); + // One GetTextureUnitObject per touched unit in the rebuild walk below. + MG_Util::PipeStats::AddCalls( + MG_Util::PipeStats::CallClass::AccessorCalls, + maxTouchedUnit >= 0 ? static_cast(maxTouchedUnit) + 1u : 0u); + } g_unitTextureSyncListValid = false; g_unitTextureSyncList.clear(); for (Int index = 0; index <= maxTouchedUnit; ++index) { - auto& unit = MG_State::pGLContext->GetTextureUnitObject(index); + auto& unit = MGB_CTX->GetTextureUnitObject(index); for (const auto& bindingSlot : unit.GetAllBindingSlots()) { auto& textureObject = bindingSlot.GetBoundObject(); // An image-less default texture (name 0) is the slot's initial / "unbound" @@ -1580,15 +3479,45 @@ namespace MobileGL::MG_Backend::DirectGLES { // registry keeps a backend object alive until its frontend texture expires, which an // attached texture cannot. A renderbuffer-only FBO - the common Minecraft frame - // reduces to the key compare and an empty loop. - const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); +#if MOBILEGL_PIPE_PUSH && MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb): under a transport with the framebuffer bit set, BOTH attachment lists + // are answered from the two records and neither binding slot is touched. The + // pre-handle pair below stays compiled and stays the monolith path (ruling 1). + if (FramebufferRecordArmIsMandatory()) { + SyncFramebufferAttachmentTexturesByRecord(); + return; + } +#endif + const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw); const auto& currentFBO = drawSlot.GetBoundObject(); if (currentFBO) { const Uint16 fboSlotVersion = drawSlot.GetVersion(); const Uint16 fboObjectVersion = currentFBO->GetObjectVersion(); +#if MOBILEGL_PIPE_PUSH + Bool fboRecordKeyed = false; + Uint64 fboContentHash = 0; + // P4a decline-site F8 (draw list): K - a memo-KEY selection, not a decline; see + // the read list's F8 note above. No flip taken at the verification round. + if (FramebufferSubsystemEnabled()) { + const auto* drawRecord = BoundFramebufferRecord(FramebufferTarget::Draw); + if (drawRecord != nullptr && !MG_Pipe::MGPipeHandleIsNull(drawRecord->Fbo)) { + fboRecordKeyed = true; + fboContentHash = drawRecord->ContentHash; + } + } +#endif const Bool fboListValid = g_fboTextureSyncListFbo == currentFBO.get() && +#if MOBILEGL_PIPE_PUSH + g_fboTextureSyncListRecordKeyed == fboRecordKeyed && + (fboRecordKeyed + ? g_fboTextureSyncListContentHash == fboContentHash + : (g_fboTextureSyncListSlotVersion == fboSlotVersion && + g_fboTextureSyncListObjectVersion == fboObjectVersion)) && +#else g_fboTextureSyncListSlotVersion == fboSlotVersion && g_fboTextureSyncListObjectVersion == fboObjectVersion && +#endif g_fboTextureSyncListContextId == keys.contextId && g_fboTextureSyncListContextGeneration == g_backendContextGeneration && PairingsIntact(g_fboTextureSyncList); @@ -1619,11 +3548,21 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fboTextureSyncListObjectVersion = fboObjectVersion; g_fboTextureSyncListContextId = keys.contextId; g_fboTextureSyncListContextGeneration = g_backendContextGeneration; +#if MOBILEGL_PIPE_PUSH + g_fboTextureSyncListContentHash = fboContentHash; + g_fboTextureSyncListRecordKeyed = fboRecordKeyed; +#endif } } else { g_fboTextureSyncListFbo = nullptr; g_fboTextureSyncList.clear(); } + +#if MOBILEGL_PIPE_PUSH + // D-E3: and then the READ framebuffer's, which nothing else in this backend gives + // texture parameters to. + SyncReadFramebufferTextureAttachments(keys, currentFBO.get()); +#endif } void SyncNeccessaryTextures() { SyncNeccessaryTextures(CaptureDrawTextureSyncKeys()); } @@ -1693,33 +3632,76 @@ namespace MobileGL::MG_Backend::DirectGLES { // draw-path staleness check below at one integer test. static Uint g_imageUnitHighWaterMark = 0; - void SyncImageTextureBinding(Uint unit) { -#ifdef TRACY_ENABLE - ZoneScopedC(TRACY_ZONECOLOR_BACKEND); -#endif - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(unit)); - TrackWritableImageBufferUnit(unit, IsWritableImageBufferTexture(imageBinding)); - if (imageBinding.Texture && unit + 1 > g_imageUnitHighWaterMark) { +#if MOBILEGL_PIPE_PUSH && MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb): the eager glBindImageTexture funnel's HANDLE ARM, and it binds nothing. + // + // `bind_shader_image` is an unbarriered row (§2.2) whose backend entry point takes GL + // arguments and a GL NAME - the record's own MGPipeHandle (MGPImageBind::Res) stops at + // the sink - so there is no way to resolve the texture's twin from what arrives here, + // and the frontend binding this used to read is a BARRIER_PULLED row. + // + // Nothing is lost by deferring: SyncImageTextureBindings at the next validate point + // re-binds every unit up to the mark from the applier's own MGPImageView array, and the + // sink bumps the texture shutter serial immediately after this call + // (MGPipeApplierNoteTextureStateMoved), which is one of the four values the sweep's gate + // is keyed on - so the sweep that follows this bind cannot be suppressed. An image unit + // has no reader but a shader, and no shader runs between here and that validate point. + // + // WHAT MUST STILL HAPPEN HERE is the high-water mark: it is the "no draw in this context + // can be reading an image" early-out, so a unit that is given a texture without raising + // it would be skipped by every sweep afterwards. + static void NoteImageUnitBoundWithoutReadingTheFrontend(Uint unit, Bool holdsTexture) { + if (!holdsTexture) return; + if (unit + 1 > g_imageUnitHighWaterMark) { g_imageUnitHighWaterMark = unit + 1; } - if (!imageBinding.Texture) { - g_GLESFuncs.glBindImageTexture(unit, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); - return; - } - - auto& backendTexture = SyncTextureObjectToBackend(imageBinding.Texture, true); - const Bool layerable = SupportsLayeredImageBinding(imageBinding.Texture->GetTarget()); - const GLboolean layered = layerable ? imageBinding.Layered : GL_FALSE; - const GLint layer = layerable ? imageBinding.Layer : 0; - // The bind half of the image-format widening. SyncTextureObjectToBackend has just - // allocated this texture's storage in the core carrier of its format (the call above - // is the one that marks it image-bindable), and glBindImageTexture's `format` has to - // name the storage the texture really has: a GL_RG32F bind is GL_INVALID_VALUE on - // Adreno for nineteen of the twenty-six non-core formats and on both Malis for - // twenty-five, and every driver that DOES accept a narrow texture through a wide - // image accepts it silently, reading and writing out of bounds. The frontend's own - // ImageTextureBinding keeps the application's format untouched, so - // GL_IMAGE_BINDING_FORMAT still answers what was passed in. + } +#endif + // ---- P5e (fb, CONTRACT-P5E.md §5.4 / ruling 16 / ID-94): the image unit's bind ------ + // + // P4a e3's ResolveShaderImageRecord IS GONE, identity test, I5 seam log and all. It + // existed because there were TWO answers for one unit - the record and the frontend + // binding - and it had to decide which of them the driver was driven from; its identity + // test (`view.Res != HandleOf(boundTexture)`) was itself a client-allocator probe on the + // apply thread. Under a transport there is no second answer left: the record IS the + // unit, so the arm below reads it and nothing corroborates it. + // + // AND ACCESS IS DECODED. The comment this replaced said MGPImageView::Access carried + // "an ENCODING whose definition belongs to the client emitter and does not exist at the + // contract commit". That was STALE at the time and is what ruling 16 / ID-94 corrects: + // MGPipeEncodeImageAccess has folded the three GL names into 0/1/2 since P4a, and c0e + // moved the numbers into MG_Pipe/MGPipeValueTypes.h so one table serves both roles. + // Reading the frontend's GLenum "because it is the same value by construction" was the + // last field of this record the server declined to believe. + + // The bind itself, factored out of both arms, because the format rules below are the + // part that is easy to get subtly different in two copies - and a divergence there is + // an out-of-bounds image read on nineteen of twenty-six formats, not a wrong pixel. + // Everything it takes is a VALUE: the arm above it decides where each one came from. + // + // COMPILED IN THE PULL BUILD TOO, and that is a deliberate exception to the D-P + // discipline the renderbuffer macros keep (each arm's expression at its original site). + // A macro cannot carry a hundred lines of format reasoning, and the alternative was the + // same hundred lines twice; the function is `static` with ONE caller in the pull build, + // so it has no symbol of its own there and the code it generates is the code that was + // written inline before. Named here rather than discovered by a G1 diff. + // NOLINTNEXTLINE(readability-function-cognitive-complexity) + static void IssueImageTextureBind(Uint unit, BackendTextureObject& backendTexture, + TextureTarget textureTarget, TextureInternalFormat textureFormat, + GLenum appFormat, GLint level, Bool layeredRequested, + GLint layerRequested, GLenum access) { + const Bool layerable = SupportsLayeredImageBinding(textureTarget); + const GLboolean layered = layerable ? static_cast(layeredRequested) : GL_FALSE; + const GLint layer = layerable ? layerRequested : 0; + // The bind half of the image-format widening. The storage sync has just allocated + // this texture's storage in the core carrier of its format (the call above is the + // one that marks it image-bindable), and glBindImageTexture's `format` has to name + // the storage the texture really has: a GL_RG32F bind is GL_INVALID_VALUE on Adreno + // for nineteen of the twenty-six non-core formats and on both Malis for twenty-five, + // and every driver that DOES accept a narrow texture through a wide image accepts it + // silently, reading and writing out of bounds. The frontend's own ImageTextureBinding + // keeps the application's format untouched, so GL_IMAGE_BINDING_FORMAT still answers + // what was passed in. // // Widened from the format the APPLICATION named rather than from the texture's own, // because GL lets the two differ inside one format class and the shader was widened @@ -1747,30 +3729,132 @@ namespace MobileGL::MG_Backend::DirectGLES { // format it asked for so that a samplerBuffer reading the same buffer texture - which // is NOT subscript-rewritten - still sees whole texels. See // BackendTextureObject::m_bufferImageSplitViewId. - GLenum bindFormat = imageBinding.Format; - GLuint bindTextureId = backendTexture->GetBackendTextureId(); - if (imageBinding.Texture->GetTarget() == TextureTarget::TextureBuffer) { - if (TextureImpl::GetImageBindableBufferSplitFormat(imageBinding.Texture->GetFormat()) != - GL_UNKNOWN_MGL) { + GLenum bindFormat = appFormat; + GLuint bindTextureId = backendTexture.GetBackendTextureId(); + if (textureTarget == TextureTarget::TextureBuffer) { + if (TextureImpl::GetImageBindableBufferSplitFormat(textureFormat) != GL_UNKNOWN_MGL) { if (const GLenum boundFormatSplit = TextureImpl::GetImageBindableBufferSplitFormat( - MG_Util::ConvertGLEnumToTextureInternalFormat(imageBinding.Format)); + MG_Util::ConvertGLEnumToTextureInternalFormat(appFormat)); boundFormatSplit != GL_UNKNOWN_MGL) { bindFormat = boundFormatSplit; - if (const Uint splitViewId = backendTexture->GetBufferImageSplitViewId(); + if (const Uint splitViewId = backendTexture.GetBufferImageSplitViewId(); splitViewId != 0) { bindTextureId = splitViewId; } } } - } else if (TextureImpl::GetImageBindableStorageWidening(imageBinding.Texture->GetFormat())) { + } else if (TextureImpl::GetImageBindableStorageWidening(textureFormat)) { const auto boundFormatWidening = TextureImpl::GetImageBindableStorageWidening( - MG_Util::ConvertGLEnumToTextureInternalFormat(imageBinding.Format)); + MG_Util::ConvertGLEnumToTextureInternalFormat(appFormat)); if (boundFormatWidening) { bindFormat = boundFormatWidening.InternalFormat; } } - g_GLESFuncs.glBindImageTexture(unit, bindTextureId, imageBinding.Level, - layered, layer, imageBinding.Access, bindFormat); + g_GLESFuncs.glBindImageTexture(unit, bindTextureId, level, layered, layer, access, bindFormat); + } + +#if MOBILEGL_PIPE_PUSH + // MGPImageView::Access -> the GL token glBindImageTexture takes. The three numbers are + // MGPipeValueTypes.h's, so this is a spelling change and not a second table; anything + // else on the wire is Fatal{ProtocolCorruption, "ImageView.Access"} AT THE READER, + // which is what the value header's comment asks for (it may not log, so it answers the + // question and the caller owns the refusal). + static GLenum GLAccessForImageView(const MG_Pipe::MGPImageView& view) { + if (!MGPipeImageAccessIsValid(view.Access)) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"ImageView.Access\"} An image record " + "carries Access=%u at unit %u, and only 0/1/2 are defined.", + static_cast(view.Access), static_cast(view.Unit)); + std::abort(); + } + switch (MGPipeDecodeImageAccess(view.Access)) { + case MGPipeImageAccess::WriteOnly: return GL_WRITE_ONLY; + case MGPipeImageAccess::ReadWrite: return GL_READ_WRITE; + case MGPipeImageAccess::ReadOnly: + case MGPipeImageAccess::Count: break; + } + return GL_READ_ONLY; + } + + // A writable buffer image, said by the record: the access byte and the resource + // descriptor's storage kind, neither of which is a frontend read. Same question + // IsWritableImageBufferTexture asks of the frontend binding. + static Bool IsWritableImageBufferView(const MG_Pipe::MGPImageView& view) { + if (MG_Pipe::MGPipeHandleIsNull(view.Res)) return false; + if (!MGPipeImageAccessIsValid(view.Access)) return false; + if (!MGPipeImageAccessWrites(MGPipeDecodeImageAccess(view.Access))) return false; + const auto* record = PipeTextureRecordForHandle(view.Res); + return record != nullptr && + static_cast(record->Desc.StorageKind) == TextureStorageType::Buffer; + } + + // The record form. `Res` is the texture, the four well-defined fields are the record's, + // Access is decoded, and the texture's own TARGET and STORAGE FORMAT - the two + // properties the bind rules need and MGPImageView has no room for (24 bytes, no pad) - + // come off the RESOURCE DESCRIPTOR, which is where the server already keeps them. + void SyncImageTextureBinding(const MG_Pipe::MGPImageView& view) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + const Uint unit = static_cast(view.Unit); + if (unit >= g_writableImageBufferUnits.size()) return; + TrackWritableImageBufferUnit(unit, IsWritableImageBufferView(view)); + if (MG_Pipe::MGPipeHandleIsNull(view.Res)) { + g_GLESFuncs.glBindImageTexture(unit, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + return; + } + if (unit + 1 > g_imageUnitHighWaterMark) { + g_imageUnitHighWaterMark = unit + 1; + } + const auto* resource = PipeTextureRecordForHandle(view.Res); + if (resource == nullptr) { + // A record naming a texture the applier has no descriptor for is a seam defect, + // not a binding: there is nothing to allocate image-bindable storage from and + // nothing to widen against. Unbind rather than leave the unit on whatever the + // previous draw put there. + MGLOG_E_ONCE("MGPipe: image unit %u names texture {%u, %u}, which has no applier " + "resource record; unbinding the unit", + static_cast(unit), view.Res.Slot, view.Res.Gen); + g_GLESFuncs.glBindImageTexture(unit, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + return; + } + const auto textureFormat = static_cast(resource->Desc.InternalFormat); + const TextureTarget textureTarget = PipeTextureTargetForHandle(view.Res); + auto& backendTexture = SyncTextureToBackendByHandle(view.Res, /*imageBindableStorageRequired=*/true); + if (!backendTexture) return; + IssueImageTextureBind(unit, *backendTexture, textureTarget, textureFormat, + static_cast(view.InternalFormat), static_cast(view.Level), + view.Layered != 0, static_cast(view.Layer), + GLAccessForImageView(view)); + } +#endif // MOBILEGL_PIPE_PUSH + + // The pre-handle form: the MONOLITH one, and now purely frontend. P4a e3 read the four + // well-defined fields off the record HERE whenever ResolveShaderImageRecord could + // corroborate the identity; P5e takes that whole apparatus out (§5.4) and gives the + // record its OWN overload above, which is the arm a transport selects (ruling 1). What + // is left is what the pull build compiles and what a push-monolith build runs, and the + // values it binds are the same ones either way - the client copies the unit's own + // binding, which is the argument P4a's own comment made for reading Access here. + void SyncImageTextureBinding(Uint unit) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + auto& imageBinding = MGB_CTX->GetImageTextureBinding(static_cast(unit)); + TrackWritableImageBufferUnit(unit, IsWritableImageBufferTexture(imageBinding)); + if (imageBinding.Texture && unit + 1 > g_imageUnitHighWaterMark) { + g_imageUnitHighWaterMark = unit + 1; + } + if (!imageBinding.Texture) { + g_GLESFuncs.glBindImageTexture(unit, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + return; + } + + auto& backendTexture = SyncTextureObjectToBackend(imageBinding.Texture, true); + if (!backendTexture) return; + IssueImageTextureBind(unit, *backendTexture, imageBinding.Texture->GetTarget(), + imageBinding.Texture->GetFormat(), imageBinding.Format, + static_cast(imageBinding.Level), imageBinding.Layered != 0, + static_cast(imageBinding.Layer), imageBinding.Access); } // A buffer texture bound to a WRITABLE image unit is a buffer the shader is about to @@ -1785,11 +3869,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // glBindImageTexture performs: that one runs before any shader has touched the buffer, // and flagging there would pull the driver's copy over a shadow the application may // still be writing into. + // + // P5e (fb, CONTRACT-P5E.md §5.4): DELETED UNDER A TRANSPORT, and it is one edit paired + // with sb's deletion of the backend's storage-block GPU-write marks. The two were one + // mechanism: the backend told the frontend's buffer shadow "a shader wrote through + // this" because only the backend knew which bindings a dispatch could reach. Under a + // split the CLIENT owns that set and keeps it itself (MG_Impl/Pipe/GpuWritePending.h), + // so the mark has an owner on the side that reads it - and this body cannot run there + // anyway: every line of it is a frontend read (the image binding, the downcast to + // TextureObjectBuffer, the buffer binding slot) plus a HandleOfBuffer probe behind + // MarkBufferGpuWritten. The gate is at the top rather than at the two call sites so + // there is one statement of it, and the unit-tracking bookkeeping below stays out of + // reach of an apply thread entirely. void MarkWritableImageBufferTexturesGpuWritten() { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) return; +#endif if (g_writableImageBufferUnitCount == 0) return; for (Uint unit = 0; unit < g_writableImageBufferUnits.size(); ++unit) { if (!g_writableImageBufferUnits[unit]) continue; - const auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(unit)); + const auto& imageBinding = MGB_CTX->GetImageTextureBinding(static_cast(unit)); if (!IsWritableImageBufferTexture(imageBinding)) { TrackWritableImageBufferUnit(unit, false); continue; @@ -1797,7 +3896,11 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* textureBuffer = static_cast(imageBinding.Texture.get()); const auto& bufferObject = textureBuffer->GetBufferBindingSlot().GetBoundObject(); +#if MOBILEGL_PIPE_PUSH + BufferImpl::MarkBufferGpuWritten(bufferObject); +#else if (bufferObject) bufferObject->MarkGpuWritten(); +#endif } } @@ -1809,6 +3912,91 @@ namespace MobileGL::MG_Backend::DirectGLES { // limit raises GL_INVALID_VALUE on every dispatch. const Uint unitCount = std::min(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS, static_cast(std::max(g_GLESCapabilities.MaxImageUnits, 0))); +#if MOBILEGL_PIPE_PUSH + // P4a e3 (D-G2, D-J2), CORRECTED BY THE REVIEW'S MAJOR-1. + // + // THE RECORD'S WINDOW IS NOT THIS SWEEP'S MEMBERSHIP. D-J2 and PipeApply.h:461-464 + // are a rule about RECORD RETENTION - entries outside the var-tail window are not + // cleared in the applier - and say nothing about which DRIVER units a re-issue + // sweep has to visit. The membership this loop needs is "every unit the driver + // currently has an image on", and that is g_imageUnitHighWaterMark, which is + // server-owned by construction: SyncImageTextureBinding is the only path to + // glBindImageTexture and raises the mark on every unit it hands a texture, so the + // mark is monotonic and covers every unit a re-mint could strand. + // + // The first version of this arm walked [Start, Start + Count) and returned, which + // is the SMALLER-WINDOW failure direction DEV-3 refused for keys.maxTouchedUnit: if + // set_shader_images is the program-RESOLVED set, a program declaring only unit 0 + // pushes a window of [0,1), and unit 5's image - established eagerly by an earlier + // dispatch and never revisited - is skipped. A texture re-minted between the two + // dispatches then leaves unit 5 on a deleted driver name: a write through a freed + // allocation on Adreno, a rejected dispatch on Mali. The pre-handle sweep bound + // EVERY unit precisely so that could not happen, and that guarantee is what this + // sweep exists for. So the record's window is UNIONED with the mark, never + // substituted for it, and the walk starts at 0 rather than at Start (a unit BELOW + // Start is as undescribed as one above Start + Count). + // + // A8, which the review adds to this package's assumption list: NOTHING IN THE + // CONTRACT PINS THE WINDOW'S MEMBERSHIP. PipeApply.h:461-464 says only that the + // record is the last set as received, and that Start + Count above the bound is + // Fatal{ProtocolCorruption}. Until package C documents in ImageEmit.h that the + // window covers every unit the driver holds, this union is load-bearing; the + // verification round logs (ShaderImageStart, ShaderImageCount) against the mark for + // a Minecraft frame and says whether it can ever be relaxed. + // + // What the record still buys is real and is e3's actual deliverable: the four field + // VALUES come off it inside SyncImageTextureBinding, and the walk stops at the + // high-water mark instead of at the device's MaxImageUnits. The units in + // [end, unitCount) the pre-handle arm additionally visits have never been given a + // texture through the only funnel that can give one, so re-binding 0 on them is a + // provable no-op. + { + const auto& st = MG_Pipe::MGPipeApplier(); + // P4a decline-site I6: M - a set that never arrived means the full pre-handle + // sweep below, which is the SAFE (wider) direction. SILENT HERE, CONFIRMED AT + // THE VERIFICATION ROUND, and it is silent here because I2 says it once per + // process from inside that sweep, where the unit that actually holds an image + // texture can be named. Two lines for one condition is how a grep's count + // stops meaning anything. + if (SamplerSubsystemEnabled() && st.ShaderImageCount != 0) { + // P4a decline-site I7: the window/mark UNION (MAJOR-1, fixed here); no flip + // remains at the verification round, only the A8 measurement above. + // + // P5e (fb) KEEPS THE UNION and says so again, because ruling 7 / ID-86 make + // it contract text now (§5.4: "never narrowed to the window"). Under a + // transport the walk additionally READS the applier's array rather than the + // frontend binding at each unit - a unit inside the mark but outside the + // window keeps whatever MGPImageView the last set that covered it left + // there (D-J2: entries outside the window are not cleared), which is the + // record of the very binding that raised the mark. + const Uint32 end = std::min( + std::max(st.ShaderImageStart + st.ShaderImageCount, + static_cast(g_imageUnitHighWaterMark)), + static_cast(unitCount)); +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + for (Uint32 unit = 0; unit < end; ++unit) { + if (unit >= st.BoundShaderImages.size()) break; + SyncImageTextureBinding(st.BoundShaderImages[unit]); + } + return; + } +#endif + for (Uint32 unit = 0; unit < end; ++unit) { + SyncImageTextureBinding(unit); + } + return; + } + } +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // The wide pre-handle sweep reads the frontend binding of every unit, which an + // apply thread may not do. Under a transport there is nothing for it to do anyway: + // g_imageUnitHighWaterMark is raised only by the funnel above, so if no set has + // ever arrived no unit has ever been given an image, and re-binding 0 on units that + // never held one is the no-op the comment above already proved. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) return; +#endif for (Uint unit = 0; unit < unitCount; ++unit) { SyncImageTextureBinding(unit); } @@ -1826,6 +4014,15 @@ namespace MobileGL::MG_Backend::DirectGLES { static Uint64 g_imageSweepSamplingGeneration = 0; static Uint g_imageSweepBackendContextGeneration = 0; static Bool g_imageSweepValid = false; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, ruling 7 / ID-86): the record arm's three applier serials. Held in their own + // fields beside the frontend pair rather than reusing them, for the reason the two + // framebuffer list keys are held apart - one field carrying two key shapes is how a + // stale half gets compared against a live one. + static Uint64 g_imageSweepShaderImagesSerial = 0; + static Uint64 g_imageSweepTextureShutterSerial = 0; + static Uint64 g_imageSweepContextSerial = 0; +#endif // The sweep is a glBindImageTexture per unit, so it must not run per draw: the gate is the // frontend's sampling-resolution generation, which TextureObjectBase::BumpShapeVersion @@ -1834,8 +4031,58 @@ namespace MobileGL::MG_Backend::DirectGLES { // the obvious choice): a texture that is bound ONLY to an image unit is re-minted inside // this very sweep, so a backend-side trigger would be bumped after the gate had already // declined to run it. + // + // P4a e3 (D-G4) leaves BOTH properties of this gate exactly as they are, and says so + // because they are the kind of thing a later optimisation deletes: + // + // 1. the g_imageUnitHighWaterMark == 0 early-out is what makes every Minecraft draw + // pay one integer test, and it is asked BEFORE anything reads a record; + // 2. the gate key stays the FRONTEND sampling-resolution generation and is + // deliberately not re-keyed onto a server-owned serial, for the reason above: a + // texture bound only to an image unit is re-minted INSIDE this sweep, so an epoch + // the server bumps would move after the gate had already declined to run it. The + // client's own NewShaderImages shutter mixes the same three frontend counters + // (texture content, texture params, the program's image-unit version), so the + // property is preserved on both sides by construction rather than by agreement. + // + // P5e (fb, ruling 7 / ID-86): PROPERTY 1 IS UNTOUCHED. PROPERTY 2 IS RE-KEYED, and the + // argument behind it is kept rather than dropped - it is what decides WHICH four values + // the new key may be made of. + // + // The frontend sampling-resolution generation cannot be read on an apply thread, so the + // key becomes (ShaderImagesSerial, TextureShutterSerial, ContextSerial, + // g_backendContextGeneration). Three of those are APPLIER-DERIVED - they move when the + // client's own shutters send a new set of images, new texture content or new texture + // parameters, i.e. at exactly the moments the frontend counter moved - and the fourth + // is the backend's ES-context generation, which the old key already carried. + // + // NONE OF THEM IS A BACKEND RE-MINT COUNTER, and that is the whole of the ruling: the + // hazard the old comment names is a texture bound ONLY to an image unit being re-minted + // INSIDE this sweep, which would bump a backend-side epoch after the gate had already + // declined to run. An applier serial moves when the CLIENT said something, strictly + // before the sweep the saying provoked, so the property survives the re-key intact. + // g_backendContextGeneration is not a counter of that kind either - it moves when the + // ES context is rebuilt, which no sweep does. void SyncImageTextureBindingsForDraw(const DrawTextureSyncKeys& keys) { if (g_imageUnitHighWaterMark == 0) return; +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto& st = MG_Pipe::MGPipeApplier(); + if (g_imageSweepValid && g_imageSweepShaderImagesSerial == st.ShaderImagesSerial && + g_imageSweepTextureShutterSerial == st.TextureShutterSerial && + g_imageSweepContextSerial == st.ContextSerial && + g_imageSweepBackendContextGeneration == g_backendContextGeneration) { + return; + } + SyncImageTextureBindings(); + g_imageSweepShaderImagesSerial = st.ShaderImagesSerial; + g_imageSweepTextureShutterSerial = st.TextureShutterSerial; + g_imageSweepContextSerial = st.ContextSerial; + g_imageSweepBackendContextGeneration = g_backendContextGeneration; + g_imageSweepValid = true; + return; + } +#endif if (g_imageSweepValid && g_imageSweepContextId == keys.contextId && g_imageSweepSamplingGeneration == keys.samplingGeneration && g_imageSweepBackendContextGeneration == g_backendContextGeneration) { @@ -1861,6 +4108,360 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fboSyncedBackendIdGenerations[SizeT(target)] = g_attachmentBackendIdGeneration; } +#if MOBILEGL_PIPE_PUSH + // P4a e1 (D-C2, D-B3). The framebuffer family's handle arm. + // + // THE FOUR-PART MEMO BECOMES TWO VALUES, and the halves are not symmetric. + // g_fboSyncedSlotVersions / ObjectVersions / Objects are three answers to one question + // - "has the state the client would describe moved?" - asked of the frontend from the + // backend. The applier answers it with one number: FramebufferSerial is bumped by the + // applier before the backend is told anything, on every set_framebuffer_state it + // accepts, and a record the client's ContentHash suppressor withheld provably describes + // state that did not move. So one Uint64 compare replaces the three. + // + // The FOURTH stays, and must: g_attachmentBackendIdGeneration answers "did *I* re-mint + // a driver texture id", which no client-side version can answer, and dropping it is the + // shape of bug commit d7655247 fixed on the buffer side (D-B3, D-O). The ES context + // generation joins it for the same reason the legacy arm's other memos carry it - the + // driver FBO names die with the context. + // Kept PER TARGET, like the arrays it replaces, and for the one reason that survives + // the collapse: ForceBindCurrentFBO syncs a single target and has to be able to say so. + // What collapses is the three-value "has the state moved" question into one serial. + struct SyncedFramebufferSerialMemo { + Uint64 serial = 0; // the FramebufferSerial this target was last synced at + Uint64 backendIdGeneration = 0; + Uint contextGeneration = 0; + Bool valid = false; + }; + static Array + g_fboSyncedSerials{}; + + // Whether the last SyncCurrentFBO found the two records describing the two bindings. + // BindCurrentFBO's handle arm reads it instead of re-asking, because the whole value + // of that arm is that it does NOT read the binding slot - and because every entry + // point that binds runs the sync first, immediately before (PrepareForDraw, Clear, the + // blits, the DSA clears, the readbacks). It starts false and every decline clears it, + // so a bind that somehow arrives without a preceding sync takes the pre-handle arm + // rather than trusting a record nothing checked. + static Bool g_fboRecordsTrusted = false; + + // A9 (the review's MAJOR-4, and it is PACKAGE D's fix, not this file's): every call + // site of this function in DirectGLES.cpp is paired with + // FramebufferImpl::InvalidateFramebufferBindingCache() - all six, verified - but that + // function lives in Managers.cpp (D's file for the whole phase) and has three further + // callers this package cannot reach: MG_Test/SanityTest.cpp's ScopedStateGuardMocks:: + // ResetShadows and ScopedBackendTwinMocks' ctor and dtor. Those three clear the + // pre-handle trio and leave g_fboSyncedSerials and g_fboRecordsTrusted stale across a + // GLES function-table swap - harmless while every arm declines, but on the integrated + // tree with bit 9 set a fixture can be entered with the records trusted and a memo + // claiming a target is synced, so BindCurrentFBO would bind from a record while the + // mock table is installed. THE FIX IS TO CALL THIS FROM INSIDE + // InvalidateFramebufferBindingCache so no caller can forget; it is one line in D's + // file and it is on D's rework list. + static void InvalidateFramebufferHandleArmMemos() { + for (auto& memo : g_fboSyncedSerials) memo.valid = false; + g_fboRecordsTrusted = false; + } + + // Record that `target` now reflects the applier's record as of `serial`. The two + // generations ride along for the reason D-B3 keeps them: they answer questions about + // the DRIVER's own ids that no client-side version can answer. + // The stamp's opposite, for a path that leaves a target correctly bound WITHOUT having + // consulted the record (MINOR-3, ForceBindCurrentFBO). Costs one extra sync at worst. + static void InvalidateSyncedFramebufferSerial(FramebufferTarget target) { + g_fboSyncedSerials[SizeT(target)].valid = false; + } + + static void StampSyncedFramebufferSerial(FramebufferTarget target, Uint64 serial) { + auto& memo = g_fboSyncedSerials[SizeT(target)]; + memo.serial = serial; + memo.backendIdGeneration = g_attachmentBackendIdGeneration; + memo.contextGeneration = g_backendContextGeneration; + memo.valid = true; + } + + // The seam check, and it is the reason this arm can be trusted at all on a tree where + // the client half is mid-landing. The record says which framebuffer each binding + // holds; the binding slot says the same thing. If they disagree, the record is stale + // or mis-keyed and driving the driver from it would sync - or skip - the wrong + // framebuffer, which is the one failure this path could produce that no pixel test on + // this tree could see. Disagreement DECLINES the whole arm rather than fixing up one + // target, because the two records are emitted together and one of them being wrong + // says nothing good about the other. + // + // The default framebuffer is compared through IsDefault rather than through Fbo, + // because the reserved handle {0,1} is not the handle the twin table minted for the + // default FramebufferObject; comparing it against HandleOf would fail for the one case + // the reserved handle exists to make easy. This is the identity comparison D-C1 + // retires, kept here ONLY as a consistency check and only until package D's twin API + // takes the record instead of the object - at which point there is no second answer + // left to disagree with. + // + // P5e (fb, CONTRACT-P5E.md §5.4): MONOLITH-ONLY. The sentence above ends "...and only + // until package D's twin API takes the record instead of the object - at which point + // there is no second answer left to disagree with". That point is this package: the + // twin's sync takes the handle, the record was resolved from the applier's OWN bound + // handle, and the only thing this could still consult is the binding slot - a + // BARRIER_PULLED row an apply thread may not read. So under a transport the check does + // not run at all, rather than running on a value it is not allowed to have. + static Bool FramebufferRecordMatchesBinding(FramebufferTarget target, + const MG_Pipe::MGPFramebufferState& record) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (FramebufferRecordArmIsMandatory()) return true; +#endif + const auto& bound = GetFramebufferBindingSlotChecked(target).GetBoundObject(); + const Bool boundIsDefault = + !bound || bound == MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO; + if ((record.IsDefault != 0) != boundIsDefault) return false; + if (boundIsDefault) return true; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd): the object-handle half of the identity check is a client-allocator + // probe (T2). Under an active transport the record is the only statement of + // identity (rule E) and the check does not run - the record was resolved from the + // applier's OWN bound handle, so it is this binding's by construction (ID-19). + // Monolith keeps the corroboration verbatim. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) return true; +#endif + return record.Fbo == g_backendFramebufferObjects.HandleOf(bound.get()); + } + + static Bool SyncedFramebufferSerialIsCurrent(FramebufferTarget target, Uint64 serial) { + const auto& memo = g_fboSyncedSerials[SizeT(target)]; + return memo.valid && memo.serial == serial && + memo.backendIdGeneration == g_attachmentBackendIdGeneration && + memo.contextGeneration == g_backendContextGeneration; + } + + // The handle arm of SyncCurrentFBO. Returns false when it declined - which is not a + // failure and is the only honest answer while no client has emitted yet. + // + // NO LIVE RECORD IS A MISS, NEVER A HIT, and it is the same rule the vertex-input arm + // states at SyncVaoAttributeBuffersByHandle. What is NOT the test, and this is a trap + // worth naming: FramebufferSerial is not "has the client ever described a framebuffer". + // MGPipeApplierReset ADVANCES every P4a working-state serial unconditionally + // (PipeApply.cpp), because a cleared window is itself a change a twin has to hear + // about - so the serial is non-zero after the first make-current even though no + // set_framebuffer_state has ever been applied, and gating on it would take this arm + // with two empty records and leave every framebuffer unsynced. The record's own Fbo + // handle is the test, and BOTH bindings have to be described before this arm can run + // at all, because it answers for both in one pass: an emitter walks {Draw, Read} and a + // Target = Both record writes both, so a half-described applier is the transitional + // state of a tree whose client half has not landed yet, and that state belongs to the + // pre-handle arm. + static Bool SyncCurrentFBOByRecord() { + g_fboRecordsTrusted = false; + const auto& st = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPFramebufferState* const drawRecord = BoundFramebufferRecord(FramebufferTarget::Draw); + const MG_Pipe::MGPFramebufferState* const readRecord = BoundFramebufferRecord(FramebufferTarget::Read); + const Bool drawUnrecorded = drawRecord == nullptr || MG_Pipe::MGPipeHandleIsNull(drawRecord->Fbo); + const Bool readUnrecorded = readRecord == nullptr || MG_Pipe::MGPipeHandleIsNull(readRecord->Fbo); + // P4a decline-site F2: S - BOTH CASES ARE LOUD, the half-described one since v2 + // (ID-19) and the NEITHER-DESCRIBED one from this verification round. + // + // EXACTLY ONE recorded: an emitter walks {Draw, Read} together and a Target = Both + // record writes both, so no correct emitter can produce it. It is the shape a + // PARTIALLY LANDED emitter produces and it is invisible in a pixel test because + // the arm just declines. + // + // NEITHER recorded WAS the transitional state of a tree whose client half had not + // landed - on this branch, before B and C were integrated, every draw was that, + // which is why v2 left it silent. Package B now emits set_framebuffer_state for + // every bound framebuffer and this function only runs with bit 9 (and therefore + // 10, 11 and 7) set, so reaching a draw with NEITHER binding described means the + // record for the bound framebuffer never arrived, or arrived at a slot whose + // generation has already moved - the applier counts that second cause separately + // in StaleFramebufferRecordLookups, so the two are told apart without a third + // message. Both are seam defects and both now say so, in the words section 8's + // one grep looks for. + if (drawUnrecorded || readUnrecorded) { + if (drawUnrecorded != readUnrecorded) { + const MG_Pipe::MGPFramebufferState& described = *(drawUnrecorded ? readRecord : drawRecord); + MGLOG_E_ONCE("A framebuffer record does not describe the binding it names: the %s " + "binding has no record while %s names {slot %u, gen %u} - a " + "half-described applier; running the pre-handle framebuffer sync.", + drawUnrecorded ? "DRAW" : "READ", drawUnrecorded ? "READ" : "DRAW", + static_cast(described.Fbo.Slot), + static_cast(described.Fbo.Gen)); + } else { + MGLOG_E_ONCE("A framebuffer record does not describe the binding it names: NEITHER " + "the DRAW nor the READ binding has one while the framebuffer " + "subsystem bit is set (bound handles {slot %u, gen %u} / " + "{slot %u, gen %u}, %llu stale-generation lookup(s) so far); " + "running the pre-handle framebuffer sync.", + static_cast( + st.BoundFramebuffer[SizeT(MG_Pipe::MGPipeFramebufferTarget::Draw)].Slot), + static_cast( + st.BoundFramebuffer[SizeT(MG_Pipe::MGPipeFramebufferTarget::Draw)].Gen), + static_cast( + st.BoundFramebuffer[SizeT(MG_Pipe::MGPipeFramebufferTarget::Read)].Slot), + static_cast( + st.BoundFramebuffer[SizeT(MG_Pipe::MGPipeFramebufferTarget::Read)].Gen), + static_cast(st.StaleFramebufferRecordLookups)); + } + return false; + } + // P4a decline-site F3: S - the framebuffer seam, already loud; the round keeps this + // wording VERBATIM because it is the stem the other two seams now share. + if (!FramebufferRecordMatchesBinding(FramebufferTarget::Draw, *drawRecord) || + !FramebufferRecordMatchesBinding(FramebufferTarget::Read, *readRecord)) { + MGLOG_E_ONCE("A framebuffer record does not describe the binding it names; " + "running the pre-handle framebuffer sync."); + return false; + } + g_fboRecordsTrusted = true; + + const FramebufferTarget fboTargets[] = {FramebufferTarget::Draw, FramebufferTarget::Read}; + for (auto& target : fboTargets) { + if (SyncedFramebufferSerialIsCurrent(target, st.FramebufferSerial)) continue; + + // Not a third accessor call: these are the two pointers F2 proved non-null a + // few lines up, so nothing here dereferences an unchecked result. + const MG_Pipe::MGPFramebufferState& record = + target == FramebufferTarget::Draw ? *drawRecord : *readRecord; + if (record.IsDefault != 0) { + // The default framebuffer, said by the record rather than by comparing the + // bound object against pDefaultFramebufferInfo->defaultFBO - which is one + // of the four identity comparisons the reserved handle {0,1} exists to + // retire (MGPipeHandles.h). Nothing to sync except the widened-attachment + // masks, which only SyncToBackend ever writes and would otherwise still + // describe the user FBO that was draw-bound before. The window surface is a + // real RGBA buffer, so nothing here is ever widened. + if (target == FramebufferTarget::Draw) { + g_alphaWidenedDrawBufferMask = 0; + g_integerColorDrawBufferMask = 0; + } + StampSyncedFramebufferSerial(target, st.FramebufferSerial); + continue; + } + + // MONOLITH GLUE, and named as such: on the monolith arm the twin's SyncToBackend + // still reads the frontend object, and the bound object is still this call's + // ARGUMENT. The IDENTITY is already the handle - GetOrCreateByHandle(record.Fbo) - + // so a recycled FBO cannot be mistaken for its predecessor here. + // + // A1, TAKEN AT THE VERIFICATION ROUND AGAINST WHAT D ACTUALLY BUILT. The twin + // is resolved BY HANDLE ONLY: `BackendPtr* GetOrCreateByHandle(MGPipeHandle)` + // (Managers.h ~453) returns a POINTER rather than the reference + // GetOrCreate(StatePtr) returns, deliberately, because it has three ways to + // decline - the legacy arm, a slot past the table's sanity bound, and a + // generation BEHIND the live entry's - and every one of them has to be visible + // here rather than answered with a parked twin. + // + // P5e (fb, CONTRACT-P5E.md §5.4): AND ON THE RECORD ARM THE OBJECT IS GONE TOO. + // `SyncToBackendByHandle(record.Fbo, target)` takes the record's own handle, so + // there is no binding slot to read, no object to hand over, and no state note + // to leave behind - NoteStateForHandle was the server's last cross-record hold + // on a frontend framebuffer, and the named blit that needed it now reads the + // record too. What is left frontend under a transport is nothing at all. + // + // THE POINTER-INVALIDATION CONTRACT (Managers.h ~381-385): a handle-arm result + // is a stable array element that only a table-GROWING GetOrCreate can move. + // GetOrCreateByHandle is exactly such a call, so its result is used and dropped + // inside this iteration and never held across another registry call. + auto* const twinSlot = g_backendFramebufferObjects.GetOrCreateByHandle(record.Fbo); + // A1's third decline. Bit 9 implies bit 10 implies bit 7, so the slot tables are + // armed whenever this function runs and the legacy-arm cause cannot fire here; + // what is left is a slot past the sanity bound or a generation behind the live + // entry's, and both are seam defects - a handle the emitter minted for a + // recycled slot and never described, or one it had already retired. The whole + // arm declines rather than this one target, for F4's reason. + if (twinSlot == nullptr) { + MGLOG_E_ONCE("A framebuffer record does not describe the binding it names: the " + "%s record's handle {slot %u, gen %u} is refused by the twin slot " + "table (live generation %u); running the pre-handle framebuffer " + "sync.", + target == FramebufferTarget::Read ? "READ" : "DRAW", + static_cast(record.Fbo.Slot), + static_cast(record.Fbo.Gen), + static_cast( + g_backendFramebufferObjects.LiveGenAt(record.Fbo.Slot))); + g_fboRecordsTrusted = false; + return false; + } + auto& backendObj = *twinSlot; + if (!backendObj) { + backendObj = MakeShared(); + } + + // THE "SAME FBO AS DRAW" SKIP IS A FIELD, not a pointer comparison against the + // object the previous iteration happened to sync. Target = Both is the client + // saying one object is bound to both bindings, so the attachment and + // draw-buffer work the DRAW pass already did is not repeated - and the read + // buffer, which is READ-target-specific and is what that skip used to drop, is + // applied unconditionally on this path. + // + // ID-27 (wire review v2, MAJOR-1): THE QUESTION IS ASKED OF THE BOUND HANDLES, + // NOT OF THE RECORD'S STORED TARGET, and it is no longer possible to ask it any + // other way in this file. `MGPFramebufferState::Target` is the target of the + // LAST EMISSION that wrote the record, and since ID-19(c) that emission may be + // a `Named` one from any of B's sixteen DSA sites - so a framebuffer really + // bound to both bindings can carry `Target == Named` and a framebuffer bound to + // neither can carry `Target == Both` left over from when it was. The skip's + // real question is "is one object bound to both bindings", and the applier + // answers it directly: BoundFramebuffer[Draw] == BoundFramebuffer[Read]. Both + // are non-null here (F2 rejected a null on either), and a handle compares by + // {slot, gen}, so a recycled slot is not its predecessor. + // + // MINOR-1's correction is RETIRED by P5e: the read buffer is no longer applied + // "FROM THE FRONTEND OBJECT" on this arm - ApplyReadBufferFromRecord resolves it + // out of MGPFramebufferState::ReadSurface, which was already the record's + // answer, and the by-handle entry is what reaches it here. + const auto& boundHandles = st.BoundFramebuffer; + const Bool sameObjectOnBothBindings = + target == FramebufferTarget::Read && + boundHandles[SizeT(MG_Pipe::MGPipeFramebufferTarget::Draw)] == + boundHandles[SizeT(MG_Pipe::MGPipeFramebufferTarget::Read)]; + +#if MOBILEGL_BUILD_DISAGGREGATED + if (FramebufferRecordArmIsMandatory()) { + if (sameObjectOnBothBindings) { + backendObj->SyncReadBufferToBackendByHandle(record.Fbo); + } else { + backendObj->SyncToBackendByHandle(record.Fbo, target); + } + StampSyncedFramebufferSerial(target, st.FramebufferSerial); + continue; + } +#endif + auto& slot = GetFramebufferBindingSlotChecked(target); + const auto& currentFBO = slot.GetBoundObject(); + // P4a decline-site F4: S - FLIPPED AT THE VERIFICATION ROUND to the FULL + // fallback the review specified. Still unreachable in practice + // (FramebufferRecordMatchesBinding maps "nothing bound" to boundIsDefault and + // a non-default record is already rejected above), but the `continue` it used + // to take would have left the other target half-run against section 1's + // invariant that a decline is a whole-arm decline. + if (!currentFBO) { + MGLOG_E_ONCE("A framebuffer record does not describe the binding it names: the " + "%s record names {slot %u, gen %u} but no FBO is bound to that " + "binding; running the pre-handle framebuffer sync.", + target == FramebufferTarget::Read ? "READ" : "DRAW", + static_cast(record.Fbo.Slot), + static_cast(record.Fbo.Gen)); + g_fboRecordsTrusted = false; + return false; + } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd): the push-monolith twin keys its applier-record lookup on the + // record's own handle (m_pushedSyncHandle) rather than on the client allocator. + // The state note is GONE with P5e: nothing resolves a framebuffer by handle and + // then asks the table for an object any more. + backendObj->m_pushedSyncHandle = record.Fbo; +#endif + if (sameObjectOnBothBindings) { + backendObj->SyncReadBufferToBackend(currentFBO); + StampSyncedFramebufferSerial(target, st.FramebufferSerial); + continue; + } + + backendObj->SyncToBackend(currentFBO, target); + StampSyncedFramebufferSerial(target, st.FramebufferSerial); + } + + return true; + } +#endif // MOBILEGL_PIPE_PUSH + void SyncCurrentFBO() { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); @@ -1869,12 +4470,41 @@ namespace MobileGL::MG_Backend::DirectGLES { TextureImpl::g_backendTextureObjects.CollectGarbageIfNeeded(); RenderbufferImpl::g_backendRenderbufferObjects.CollectGarbageIfNeeded(); +#if MOBILEGL_PIPE_PUSH + // The handle arm first, and it declines rather than half-running: with no record + // yet it hands the walk straight back to the pre-handle arm below, unchanged. + // P4a decline-site F1: M - the mask says this family is not switched on, and it + // IS D's FramebufferSubsystemEnabled() now. Silent, confirmed at the + // verification round. + if (FramebufferSubsystemEnabled() && SyncCurrentFBOByRecord()) return; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, CONTRACT-P5E.md §5.4): AND UNDER A TRANSPORT THE DECLINE IS THE END OF + // IT. The fallback below reads GetFramebufferBindingSlot, a BARRIER_PULLED row; the + // value it would read belongs to a client this apply is no longer synchronous with, + // so "run the pre-handle sync" stops being a safe default and becomes a wrong + // picture taken from torn state. Every cause of the decline is already named by the + // MGLOG_E_ONCE that produced it (F2's half-described / neither-described applier, + // F4's unbound binding, A1's refused handle); this turns that log into the abort the + // contract asks for, with the verb in the message so the strict lane's marker table + // points at the site. + if (FramebufferRecordArmIsMandatory()) { + RefuseFramebufferBindingSlotRead(); + } +#endif +#endif + const FramebufferTarget fboTargets[] = {FramebufferTarget::Draw, FramebufferTarget::Read}; MG_State::GLState::FramebufferObject* lastUpdatedFBO = nullptr; for (auto& target : fboTargets) { - auto& slot = GetFramebufferBindingSlotFast(target); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): the twin resolutions this iteration may run + // below are frontend-keyed, named debt inside the scope - P3b/P4b rekeys the + // registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + auto& slot = GetFramebufferBindingSlotChecked(target); auto& currentFBO = slot.GetBoundObject(); // The three memos together say "this target is already synced": which object is @@ -1995,7 +4625,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - Uint16 currentRenderStateVersion = MG_State::pGLContext->GetRenderStateParametersVersion(); + Uint16 currentRenderStateVersion = MGB_CTX->GetRenderStateParametersVersion(); const Bool forceFullPush = g_forceFullRenderStateResync; g_forceFullRenderStateResync = false; // The alpha discipline for widened colour attachments (see the header comment on @@ -2006,10 +4636,24 @@ namespace MobileGL::MG_Backend::DirectGLES { const Bool colorMaskWidenDirty = appliedWidenMask != g_syncedColorMaskAlphaWidenMask; if (!forceFullPush && !colorMaskWidenDirty && g_hasSyncedRenderState && currentRenderStateVersion == g_syncedRenderStateVersion) { + // Gate 1 of section 2.3.1: the steady-state cost of this whole function is + // the one Uint16 read above plus this compare. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytRenderState, /*hit=*/true); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } return; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::EsprytRenderState, /*hit=*/false); + // The version read above, the parameter-block fetch and the viewport fetch + // below - the three accessor calls this function makes unconditionally on a + // miss. The conditional sRGB capability read further down is deliberately + // NOT counted (see the inventory in PipeStats.cpp). + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); + } - const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + const auto& parameters = MGB_CTX->GetRenderStateParameters(); // The frontend has ONE version for the whole parameter block, so a per-draw blend // toggle used to re-diff all ~40 pieces of state field by field on every draw @@ -2038,7 +4682,7 @@ namespace MobileGL::MG_Backend::DirectGLES { !g_hasSyncedRenderState || std::memcmp(currentBytes + kBlendSpanEnd, syncedBytes + kBlendSpanEnd, sizeof(RenderStateParameters) - kBlendSpanEnd) != 0; - IntVec4 backendViewport = MG_State::pGLContext->GetViewport(); + IntVec4 backendViewport = MGB_CTX->GetViewport(); if (backendViewport.z() <= 0 || backendViewport.w() <= 0) { Int surfaceWidth = 0; Int surfaceHeight = 0; @@ -2121,7 +4765,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // never turns it on, so the driver has to be told to write raw. Without this a render // into an sRGB colour buffer comes back encoded once too often (the shader's own // decode on the next fetch then leaves the value one conversion short). - const Bool srgbWrites = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::FramebufferSrgb); + const Bool srgbWrites = MGB_CTX->IsCapabilityEnabled(CapabilityInput::FramebufferSrgb); if (g_GLESCapabilities.SupportsSrgbWriteControl && (forceFullPush || srgbWrites != g_syncedSrgbFramebufferWrites)) { srgbWrites ? g_GLESFuncs.glEnable(GL_FRAMEBUFFER_SRGB) @@ -2654,6 +5298,20 @@ namespace MobileGL::MG_Backend::DirectGLES { // never be consumed (the stale pair is overwritten before any consumer runs). static const MG_State::GLState::ProgramObject* g_currentDrawFrontendProgram = nullptr; static BackendProgramObjectImpl* g_currentDrawBackendProgram = nullptr; +#if MOBILEGL_PIPE_PUSH + // P5e (pg), CONTRACT-P5E.md §5.5: THE SAME STASH, KEYED ON THE HANDLE. The raw frontend + // pointer above is one of the five frontend-keyed reads this family retires: every + // consumer compared it against GetProgramForDraw().get(), which under run-ahead is a + // pointer to an object the client may already have relinked or freed. The handle arm + // compares against MGPipeApplier().DrawProgram instead - a {slot, gen} the record + // carried, which cannot be recycled behind the server's back because the gen moves with + // the slot. + // + // BOTH HALVES SURVIVE because the two arms do (ruling 1): the pointer is the monolith + // glue's key and the handle is the transport's, and SyncCurrentProgram* clears the one + // it does not use so a stale key can never be consumed. + static MG_Pipe::MGPipeHandle g_currentDrawProgramHandle = MG_Pipe::kMGPipeNullHandle; +#endif // Memo of the per-draw enabled-draw-buffers walk feeding g_fragColorBroadcastCount: // the answer is a pure function of WHICH FBO is bound and its draw-buffer edits, so @@ -2668,6 +5326,30 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool g_broadcastMemoValid = false; static Uint g_broadcastMemoCount = 1; +#if MOBILEGL_PIPE_PUSH + // P4a e1 (D-C4). The handle arm's key is the framebuffer record's ContentHash, and it + // is a COMPLETE key for this answer rather than a cheaper approximation of the trio + // above: the hash is taken over every field the record carries - Fbo included, so a + // recycled framebuffer handle cannot be suppressed against its predecessor's record, + // and DrawBuffers[8] included, so an unchanged hash provably means the draw-buffer + // array did not move, which provably means the broadcast count did not move. + // + // The ORDERING that made this read come from the frontend is preserved and is what + // makes reading a record legal here: the client emits every set_* BEFORE the verb and + // the server specialises AT the verb, so by the time this runs the record already + // describes the framebuffer this draw will use - which is exactly the property the + // frontend read was buying (a program compiled against a stale count would otherwise + // not be relinked until the draw after the one that needed it). + // + // Separate statics rather than reusing the trio's: FramebufferSerial goes from 0 to + // non-zero exactly once in a process's life, so both arms can run in one run, and one + // set of fields carrying two key shapes is how a stale half is compared against a live + // one. + static Uint64 g_broadcastMemoContentHash = 0; + static Bool g_broadcastMemoHandleValid = false; + static Uint g_broadcastMemoHandleCount = 1; +#endif + // The identity+version key above is only monotonic WITHIN one GLContext: a // library teardown + re-init frees every FramebufferObject and restarts the // draw slot's counter at zero, so a recycled FBO address with coinciding @@ -2675,35 +5357,278 @@ namespace MobileGL::MG_Backend::DirectGLES { // structurally identical SyncCurrentFBO trio (InvalidateFramebufferBindingCache). void InvalidateBroadcastMemo() { g_broadcastMemoValid = false; +#if MOBILEGL_PIPE_PUSH + g_broadcastMemoHandleValid = false; +#endif + } + +#if MOBILEGL_PIPE_PUSH + // P4a e4 (D-H6, D-H7). The ShaderCso record behind a handle, WITH the composite band + // resolved: the band starts at kMGPipeShaderCsoCompositeSlotBase and the applier keeps + // it in a second dense table indexed by (slot - base), because one composite in the + // slot-indexed vector would grow it to ~983k records. The SERVER STILL NEVER LEARNS IT + // IS A COMPOSITE - a composite handle is an ordinary ShaderCso handle and every call + // names it as one; this is an indexing detail, and it lives in one place so that only + // one reader has to know it. + static const MG_Pipe::MGPipeShaderCsoRecord* FindShaderCsoRecord(MG_Pipe::MGPipeHandle handle) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr; + const auto& st = MG_Pipe::MGPipeApplier(); + const Vector* table = nullptr; + Uint32 index = 0; + if (MG_Pipe::MGPipeIsCompositeShaderSlot(handle.Slot)) { + table = &st.CompositeShaderCsos; + index = handle.Slot - MG_Pipe::kMGPipeShaderCsoCompositeSlotBase; + } else { + table = &st.ShaderCsos; + index = handle.Slot; + } + // P4a decline-site P2: S - FLIPPED AT THE VERIFICATION ROUND to loud-once, and it + // TELLS THE TWO BANDS APART as the review requires. An out-of-range ORDINARY slot + // means the create_shader_state for this program never arrived - a client seam. + // An out-of-range COMPOSITE index is a different finding entirely: that table is + // dense and its band base is a fixed contract constant, so a handle above its end + // is a mis-decoded slot, i.e. a CONTRACT-side bug, and saying "the record has not + // arrived" about it would send the reader to the wrong package. + if (index >= table->size()) { + if (MG_Pipe::MGPipeIsCompositeShaderSlot(handle.Slot)) { + MGLOG_E_ONCE("A program record does not describe the binding it names: composite " + "ShaderCso {slot %u, gen %u} decodes to band index %u but the " + "composite table holds %llu - the band base or the decode is wrong, " + "not the emission; running the frontend's own uniform block.", + static_cast(handle.Slot), static_cast(handle.Gen), + static_cast(index), + static_cast(table->size())); + } else { + MGLOG_E_ONCE("A program record does not describe the binding it names: ShaderCso " + "{slot %u, gen %u} is above the applier's table of %llu record(s), so " + "no create_shader_state for it has been applied; running the " + "frontend's own uniform block.", + static_cast(handle.Slot), static_cast(handle.Gen), + static_cast(table->size())); + } + return nullptr; + } + const MG_Pipe::MGPipeShaderCsoRecord& record = (*table)[index]; + // P4a decline-site P3: S - FLIPPED AT THE VERIFICATION ROUND to loud-once, and it + // is ID-8's stale-generation refusal: every GetOrCreate(handle) in the phase owes + // one. On this tree a bound program whose record is dead, or whose generation has + // moved under the handle, is a death/recycle seam - the emitter kept handing out + // a handle it had retired, or minted a successor at the slot and never described + // it - and the two causes are named apart because they point at different halves + // of the client. + if (!record.Live || record.Gen != handle.Gen) { + MGLOG_E_ONCE("A program record does not describe the binding it names: ShaderCso " + "{slot %u, gen %u} finds a %s record (generation %u); running the " + "frontend's own uniform block.", + static_cast(handle.Slot), static_cast(handle.Gen), + record.Live ? "live but differently-generationed" : "dead", + static_cast(record.Gen)); + return nullptr; + } + return &record; } - void SyncCurrentProgram(const SharedPtr& currentProgram) { -#ifdef TRACY_ENABLE - ZoneScopedC(TRACY_ZONECOLOR_BACKEND); + // The record whose GLOBAL CONSTANTS may be uploaded for this program, or null. + // + // Four things have to hold before the default uniform block is taken off a record + // rather than off the frontend program, and each of them is a way the two could + // legitimately disagree on a tree whose client half is mid-landing: + // + // * the program has a handle at all, and the record at it is live; + // * the record's own Desc names that same handle - the identity check, and the seam + // where a mis-keyed emission becomes visible instead of becoming a wrong upload; + // * the version is not the ~0u NEVER-UPLOADED SENTINEL, which the client must never + // emit and which is what the record starts at; + // * the block image is at least as long as the program says the block is, because + // the upload copies GetUBOSize() bytes and a short record would read past it. + // + // Anything else and the frontend's own MapUBO answers, exactly as it does today. + // P5e (pg): `uboSize` is what the block image is checked against, and it is a PARAMETER + // now rather than a `program->GetUBOSize()` read inside the body - on the handle arm the + // size is the record's own Desc.GlobalUboSize and there is no ProgramObject to ask. + // `program` is null on that arm and is used for nothing but the monolith identity + // resolution below. + static const MG_Pipe::MGPipeShaderCsoRecord* ResolveGlobalConstantsRecordForHandle( + MG_Pipe::MGPipeHandle handle, Uint uboSize) { + const MG_Pipe::MGPipeShaderCsoRecord* const record = FindShaderCsoRecord(handle); + // P4a decline-site P4: S - FOLDED INTO P2/P3 AT THE VERIFICATION ROUND, which is + // what the review asked for and is why this one stays silent: both reasons + // FindShaderCsoRecord can answer null have just spoken for themselves, with the + // band and the generation named. A third line here would say less and fire on + // every one of them. + if (record == nullptr) return nullptr; + // P4a decline-site P5: S - THE PROGRAM SEAM, loud (MAJOR-3) and in the same + // words as the framebuffer and image seams, so one grep polices all three. No + // further flip. Unlike the image seam this one has no eager funnel to be wrong + // at: the only caller is the global-UBO upload at the draw validate point, where + // the record for THIS draw has been applied, so a descriptor that names a + // different handle than the slot it sits in is always a mis-keyed emission. + if (record->Desc.Cso != handle) { + MGLOG_E_ONCE("A program record does not describe the binding it names: the ShaderCso " + "record at {slot %u, gen %u} names {slot %u, gen %u} in its own " + "descriptor; running the frontend's own uniform block.", + static_cast(handle.Slot), static_cast(handle.Gen), + static_cast(record->Desc.Cso.Slot), + static_cast(record->Desc.Cso.Gen)); + return nullptr; + } + // P4a decline-site P6: M, then S - FLIPPED AT THE VERIFICATION ROUND, and the + // condition the review names is already the CALLER'S, so the test is not repeated + // here. The ~0u NEVER-UPLOADED SENTINEL is what the record starts at and is + // legitimate before the first set_global_constants for a program. But the only + // caller of this function is the global-UBO upload, which runs inside + // `currentProgram->GetUBOSize() > 0 && backendProgram.HasGlobalUboBlock()` - so + // reaching this line at all means bit 12 is on, this program HAS a default + // uniform block, and it is being drawn with no global constants ever emitted for + // it. That is the seam defect, and it says so. + if (record->GlobalConstantsVersion == ~Uint32{0}) { + MGLOG_E_ONCE("A program record does not describe the binding it names: ShaderCso " + "{slot %u, gen %u} still carries the never-uploaded sentinel at a draw " + "of a program with a %u-byte default uniform block; running the " + "frontend's own uniform block.", + static_cast(handle.Slot), static_cast(handle.Gen), + static_cast(uboSize)); + return nullptr; + } + // P4a decline-site P7: S, and LOUD (ID-19) - this one is not a missing record, + // it is PROTOCOL CORRUPTION. The upload copies GetUBOSize() bytes out of + // GlobalConstants, so a shorter block image is a read past the end of the + // applier's own buffer; no correct emitter can produce it, and quietly handing + // the draw back to MapUBO() would file a memory-safety bug as a perf regression. + // + // THE VERDICT IS THE ONE THE CONTRACT ALREADY SPELLS, and deliberately not a new + // one: PipeApply.cpp's "the verdict of every trip wire in this file, in one + // place" says a poison or verify build STOPS and writes the Fatal{} marker G4 + // greps the retrace logs for, while a shipped push build logs at error level and + // carries on from a defined state. So the gate lanes - which is where a seam + // defect has to be caught - never reach MapUBO() at all, and a shipped build does + // not abort a running game over a client-side bug. If the integrator wants the + // stop on every arm, it is one #if away and this comment is where to say so. + if (record->GlobalConstants.size() < static_cast(uboSize)) { +#if MOBILEGL_PIPE_POISON || MOBILEGL_PIPE_VERIFY + MGLOG_F("MGPipe: Fatal{ProtocolCorruption} " + "A program record does not describe the binding it names: the ShaderCso " + "record {slot %u, gen %u} carries a %llu-byte global-constant block for a " + "%llu-byte default uniform block.", + static_cast(handle.Slot), static_cast(handle.Gen), + static_cast(record->GlobalConstants.size()), + static_cast(uboSize)); + std::abort(); +#else + MGLOG_E_ONCE("A program record does not describe the binding it names: the ShaderCso " + "record {slot %u, gen %u} carries a %llu-byte global-constant block for a " + "%llu-byte default uniform block; running the frontend's own uniform block.", + static_cast(handle.Slot), static_cast(handle.Gen), + static_cast(record->GlobalConstants.size()), + static_cast(uboSize)); #endif - g_backendProgramObjects.CollectGarbageIfNeeded(); - SamplerImpl::g_backendSamplerObjects.CollectGarbageIfNeeded(); + return nullptr; + } + return record; + } - g_currentDrawFrontendProgram = nullptr; - g_currentDrawBackendProgram = nullptr; + // THE MONOLITH-GLUE HALF (ruling 1 / ID-81), unchanged in what it does: resolve the + // program's handle by frontend identity inside the named scope, then ask the body above. + // Reached only when `Transport == Monolith`, which is what keeps the push-monolith build + // token for token. + static const MG_Pipe::MGPipeShaderCsoRecord* ResolveGlobalConstantsRecord( + const MG_State::GLState::ProgramObject* program) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside the + // scope. P5e does not delete it - it stops REACHING it under a transport, which is + // the thing the allocator guard measures. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + // P4a decline-site P1: M - the mask says this family is not switched on, or there + // is no current program at all; it IS D's ProgramSubsystemEnabled() now. Silent, + // confirmed at the verification round. + if (!ProgramSubsystemEnabled() || program == nullptr) return nullptr; + return ResolveGlobalConstantsRecordForHandle(g_backendProgramObjects.HandleOf(program), + program->GetUBOSize()); + } +#endif - // ... || !GetSpirvStatus(): see BackendProgramObjectImpl::SyncToBackend - a - // program whose SPIR-V never arrived is linked but not drawable. - if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) { - g_GLESFuncs.glUseProgram(0); - g_lastUsedBackendProgramId = 0; - return; + // Read from the frontend rather than from the backend framebuffer sync, which + // only runs later in PrepareForDraw: a program compiled against a stale count + // would not be relinked until the draw after the one that needed it. + // + // P5e (pg): ITS OWN FUNCTION AND ITS OWN POLICY, because the three callers want three + // different answers when the record arm has nothing to say: + // + // FrontendFallback - the monolith arm, unchanged: read the draw-FBO binding slot. + // RefuseFallback - the handle arm AT A DRAW. With a live wire that binding slot is a + // BARRIER_PULLED row the client may already have moved, so a count + // read from it would compile the program against a framebuffer that + // is not the one being drawn to - silently, and only sometimes. + // The record is the answer or there is no answer (rule F). + // RecordOnly - the handle arm AT A DISPATCH. A compute program has no fragment + // stage and a dispatch has no draw framebuffer, so this count is not + // an input to what is being built; PrepareForCompute also does not + // run SyncCurrentFBO, so the framebuffer records are legitimately + // untrusted here and a refusal would abort a correct program. The + // previous resolution stands, which is what the monolith arm + // effectively does too when nothing rebound between the two calls. + enum class BroadcastCountPolicy { FrontendFallback, RefuseFallback, RecordOnly }; + + static void ResolveFragColorBroadcastCount(BroadcastCountPolicy policy) { + Bool broadcastCountResolved = false; +#if MOBILEGL_PIPE_PUSH + // The trust latch is fresh here and costs nothing: PrepareForDraw runs + // SyncCurrentFBO immediately before this, so the records have just been + // checked against the two bindings. + if (FramebufferSubsystemEnabled() && FramebufferImpl::g_fboRecordsTrusted) { + const MG_Pipe::MGPFramebufferState* const recordPtr = + BoundFramebufferRecord(FramebufferTarget::Draw); + // P4a decline-site F7: FLIPPED AT THE VERIFICATION ROUND from a silent + // decline to an assertion, because it is unreachable and a silent decline + // here is indistinguishable from a legitimate one. g_fboRecordsTrusted + // implies a record with a non-null handle on BOTH targets: + // SyncCurrentFBOByRecord sets the latch only after F2 has rejected a null + // pointer or handle on either, and it runs immediately before this in + // PrepareForDraw. Kept as a checked decline rather than deleted so a + // release build cannot dereference null if that ordering ever changes. + MOBILEGL_ASSERT(recordPtr != nullptr && !MG_Pipe::MGPipeHandleIsNull(recordPtr->Fbo)); + if (recordPtr != nullptr && !MG_Pipe::MGPipeHandleIsNull(recordPtr->Fbo)) { + const MG_Pipe::MGPFramebufferState& record = *recordPtr; + if (!g_broadcastMemoHandleValid || g_broadcastMemoContentHash != record.ContentHash) { + Uint enabledDrawBuffers = 0; + for (Uint i = 0; i < MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS; ++i) { + // -1 is FramebufferAttachmentType::None on the wire; every + // other value is an attachment index. + if (record.DrawBuffers[i] >= 0) { + enabledDrawBuffers = i + 1; + } + } + g_broadcastMemoContentHash = record.ContentHash; + g_broadcastMemoHandleCount = std::max(enabledDrawBuffers, 1); + g_broadcastMemoHandleValid = true; + } + g_fragColorBroadcastCount = g_broadcastMemoHandleCount; + broadcastCountResolved = true; + } } - // Read from the frontend rather than from the backend framebuffer sync, which - // only runs later in PrepareForDraw: a program compiled against a stale count - // would not be relinked until the draw after the one that needed it. - { - const auto& drawSlot = GetFramebufferBindingSlotFast(FramebufferTarget::Draw); +#endif + if (policy == BroadcastCountPolicy::RecordOnly) return; + if (policy == BroadcastCountPolicy::RefuseFallback && !broadcastCountResolved) { + // The record arm declined under a transport. g_fboRecordsTrusted is set by + // SyncCurrentFBOByRecord, which PrepareForDraw runs immediately before this, + // so reaching here means the framebuffer family's own records are missing or + // untrusted - and the frontend fallback below is exactly the read this phase + // exists to retire. + MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, " + "\"GetFramebufferBindingSlot@SyncCurrentProgram\"} - the draw " + "framebuffer's record did not answer the fragColor broadcast count on " + "the handle arm, and the frontend binding slot is a row the client owns"); + std::abort(); + } + if (!broadcastCountResolved) { + const auto& drawSlot = GetFramebufferBindingSlotChecked(FramebufferTarget::Draw); const auto& drawFBO = drawSlot.GetBoundObject(); const Uint16 slotVersion = drawSlot.GetVersion(); const Uint16 objectVersion = drawFBO ? drawFBO->GetObjectVersion() : 0; if (!g_broadcastMemoValid || g_broadcastMemoFbo != drawFBO.get() || - g_broadcastMemoSlotVersion != slotVersion || g_broadcastMemoObjectVersion != objectVersion) { + g_broadcastMemoSlotVersion != slotVersion || + g_broadcastMemoObjectVersion != objectVersion) { Uint enabledDrawBuffers = 0; if (drawFBO) { const auto& drawBuffers = drawFBO->GetDrawBuffers(); @@ -2721,17 +5646,58 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_fragColorBroadcastCount = g_broadcastMemoCount; } + } - BackendProgramObjectImpl* twin = g_programTwinLookupMemo.Lookup(currentProgram); - if (!twin) { - auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get()); - auto& backendObj = - backendProgramSlot ? *backendProgramSlot : g_backendProgramObjects.GetOrCreate(currentProgram); + void SyncCurrentProgram(const SharedPtr& currentProgram) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): the twin resolution below still keys on the frontend + // program object - named debt inside the scope - P3b/P4b rekeys the registry onto + // handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + g_backendProgramObjects.CollectGarbageIfNeeded(); + SamplerImpl::g_backendSamplerObjects.CollectGarbageIfNeeded(); + + g_currentDrawFrontendProgram = nullptr; + g_currentDrawBackendProgram = nullptr; + + // ... || !GetSpirvStatus(): see BackendProgramObjectImpl::SyncToBackend - a + // program whose SPIR-V never arrived is linked but not drawable. + if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) { + g_GLESFuncs.glUseProgram(0); + g_lastUsedBackendProgramId = 0; + return; + } + ResolveFragColorBroadcastCount(BroadcastCountPolicy::FrontendFallback); + + BackendProgramObjectImpl* twin = nullptr; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + auto* slot = g_backendProgramObjects.Find(currentProgram.get()); + auto& backendObj = slot ? *slot : g_backendProgramObjects.GetOrCreate(currentProgram); if (!backendObj) { backendObj = MakeShared(); } - g_programTwinLookupMemo.Store(currentProgram, backendObj.get()); twin = backendObj.get(); + } else +#endif + { +#if MOBILEGL_PIPE_LEGACY_MEMOS + twin = g_programTwinLookupMemo.Lookup(currentProgram); + if (!twin) { + auto* backendProgramSlot = g_backendProgramObjects.Find(currentProgram.get()); + auto& backendObj = backendProgramSlot ? *backendProgramSlot + : g_backendProgramObjects.GetOrCreate(currentProgram); + if (!backendObj) { + backendObj = MakeShared(); + } + g_programTwinLookupMemo.Store(currentProgram, backendObj.get()); + twin = backendObj.get(); + } +#endif } // A link-version mismatch means the program was relinked: the backend // shaders and every cache built by CacheResourceLocations (block @@ -2758,8 +5724,15 @@ namespace MobileGL::MG_Backend::DirectGLES { twin->GetSnormFallbackClampOutputMask() != g_snormFallbackClampOutputMask || twin->GetUnormFallbackClampOutputMask() != g_unormFallbackClampOutputMask || twin->GetFragColorBroadcastCount() != g_fragColorBroadcastCount || +#if MOBILEGL_PIPE_PUSH + // P5e (ID-124): ComputeShaderStorageBlockBindingSignatureOf is declared only + // under MOBILEGL_PIPE_PUSH (Managers.h:3122) and this clause read it from an + // UNGUARDED condition list, so the pull flavour did not compile. The clause is + // push-only in substance too: the signature it compares is over the override + // map the wire carries, and without the wire there is nothing to compare. twin->GetShaderStorageBlockBindingSignature() != - ComputeShaderStorageBlockBindingSignature(*currentProgram) || + ComputeShaderStorageBlockBindingSignatureOf(*currentProgram) || +#endif // A fourth of the same shape, and the reason glBindImageTexture itself does // nothing: GLSL ES demands a format layout qualifier on an image where desktop // GLSL lets a writeonly declaration omit one, so a format-less declaration is @@ -2797,23 +5770,157 @@ namespace MobileGL::MG_Backend::DirectGLES { // member set it has to be told. (twin->GetPassthroughTessControlPatchVertices() >= 0 && (twin->GetPassthroughTessControlPatchVertices() != - static_cast(MG_State::pGLContext->GetPatchVertices()) || + static_cast(MGB_CTX->GetPatchVertices()) || !BitwiseEqual(twin->GetPassthroughTessControlOuterLevel(), - MG_State::pGLContext->GetPatchDefaultOuterLevel()) || + MGB_CTX->GetPatchDefaultOuterLevel()) || !BitwiseEqual(twin->GetPassthroughTessControlInnerLevel(), - MG_State::pGLContext->GetPatchDefaultInnerLevel())))) { + MGB_CTX->GetPatchDefaultInnerLevel())))) { twin->SyncToBackend(currentProgram); } g_currentDrawFrontendProgram = currentProgram.get(); g_currentDrawBackendProgram = twin; +#if MOBILEGL_PIPE_PUSH + g_currentDrawProgramHandle = MG_Pipe::kMGPipeNullHandle; +#endif + } + +#if MOBILEGL_PIPE_PUSH + // P5e (pg), CONTRACT-P5E.md §5.5: THE SAME SYNC, FROM THE HANDLE THE RECORD CARRIED. + // Two overloads and not an `#if` inside one body (ruling 1 / ID-81): the frontend one + // above is visibly the monolith-glue half, this one names no frontend object at all. + // + // WHAT THE NINE-CLAUSE CONDITION BECOMES, and the clause COUNT does not shrink - its + // inputs move (D-H5, §5.5): + // GetLinkVersion() -> record.Serial vs GetSyncedShaderCsoSerial() + // GetImageUnitVersion() -> record.BindingsSerial vs GetSyncedBindingsSerial() + // ComputeShaderStorage...() -> record.Signature + // GetLinkStatus/SpirvStatus -> Desc.LinkStatus / Desc.SpirvStatus, above + // and the clamp masks, the broadcast count, ImageUnitFormatsStillMatch and the three + // patch clauses are unchanged - they are backend globals, fb's row and VALUE rows of the + // residual block, none of which this family owns. + void SyncCurrentProgramByHandle(MG_Pipe::MGPipeHandle cso, Bool forDraw) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + g_backendProgramObjects.CollectGarbageIfNeeded(); + SamplerImpl::g_backendSamplerObjects.CollectGarbageIfNeeded(); + + g_currentDrawFrontendProgram = nullptr; + g_currentDrawBackendProgram = nullptr; + g_currentDrawProgramHandle = MG_Pipe::kMGPipeNullHandle; + + const MG_Pipe::MGPipeShaderCsoRecord* const record = FindShaderCsoRecord(cso); + // THE SAME THREE-PART TEST THE FRONTEND ARM MAKES, from the descriptor. LinkStatus + // is a field rather than an implication (ID-88): create_shader_state is re-issued at + // every link that moves the link version and a FAILED relink of a bound program + // moves it too, so "a record exists" and "the program linked" are different + // statements. A program the frontend reports unlinked draws nothing, which is + // exactly what the monolith arm does with GetLinkStatus() == false. + if (record == nullptr || record->Desc.LinkStatus == 0 || record->Desc.SpirvStatus == 0) { + g_GLESFuncs.glUseProgram(0); + g_lastUsedBackendProgramId = 0; + return; + } + // REFUSED at a draw, record-only at a dispatch: see the policy's own note. + ResolveFragColorBroadcastCount(forDraw ? BroadcastCountPolicy::RefuseFallback + : BroadcastCountPolicy::RecordOnly); + + BackendProgramObjectImpl* const twin = ResolveProgramTwin(cso); + if (twin == nullptr) { + // ResolveProgramTwin has already named the handle. A draw with no twin binds + // nothing, which is the visible no-op Use() makes of an unusable program rather + // than a draw with somebody else's shader. + g_GLESFuncs.glUseProgram(0); + g_lastUsedBackendProgramId = 0; + return; + } + if (!twin->GetBackendProgramId() || + twin->GetSyncedShaderCsoSerial() != record->Serial || + twin->GetSyncedBindingsSerial() != record->BindingsSerial || + twin->GetSnormFallbackClampOutputMask() != g_snormFallbackClampOutputMask || + twin->GetUnormFallbackClampOutputMask() != g_unormFallbackClampOutputMask || + twin->GetFragColorBroadcastCount() != g_fragColorBroadcastCount || + twin->GetShaderStorageBlockBindingSignature() != record->Signature || + !twin->ImageUnitFormatsStillMatch() || + (twin->GetPassthroughTessControlPatchVertices() >= 0 && + (twin->GetPassthroughTessControlPatchVertices() != + static_cast(MGB_CTX->GetPatchVertices()) || + !BitwiseEqual(twin->GetPassthroughTessControlOuterLevel(), + MGB_CTX->GetPatchDefaultOuterLevel()) || + !BitwiseEqual(twin->GetPassthroughTessControlInnerLevel(), + MGB_CTX->GetPatchDefaultInnerLevel())))) { + twin->SyncToBackendByHandle(cso); + } + g_currentDrawProgramHandle = cso; + g_currentDrawBackendProgram = twin; } +#endif } // namespace PrgramImpl void BindCurrentFBO(FramebufferTarget target) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = GetFramebufferBindingSlotFast(target); +#if MOBILEGL_PIPE_PUSH + // The handle arm reads the record instead of the binding slot, which is one of the + // five GetFramebufferBindingSlotChecked call sites this phase retires. Everything the + // pre-handle arm below is careful about is careful here for the same reasons and in + // the same order: NO FAST PATH on any version (the skip is BindFramebufferId's job and + // its shadow is where the cost actually is), and the DEFAULT framebuffer is bound + // THROUGH THE SHADOW, never raw. What changes is only where the two questions - "which + // framebuffer" and "is it the default one" - are answered: the record's Fbo and its + // IsDefault byte, rather than the bound object's address and a comparison against + // pDefaultFramebufferInfo->defaultFBO. + if (FramebufferSubsystemEnabled() && FramebufferImpl::g_fboRecordsTrusted) { + const MG_Pipe::MGPFramebufferState* const recordPtr = BoundFramebufferRecord(target); + // The record's own handle is the "has this binding ever been described" test, not + // FramebufferSerial - which MGPipeApplierReset advances whether or not anything + // was ever emitted (see SyncCurrentFBOByRecord). The trust latch above is the + // other half: it says the sync that ran a moment ago found these two records + // describing these two bindings. + // P4a decline-site F5: FLIPPED AT THE VERIFICATION ROUND to an assertion, same + // argument and same shape as F7 above - the trust latch implies a record with a + // non-null handle on both targets, and a silent decline here would look exactly + // like a legitimate one. + MOBILEGL_ASSERT(recordPtr != nullptr && !MG_Pipe::MGPipeHandleIsNull(recordPtr->Fbo)); + if (recordPtr != nullptr && !MG_Pipe::MGPipeHandleIsNull(recordPtr->Fbo)) { + const MG_Pipe::MGPFramebufferState& record = *recordPtr; + if (record.IsDefault == 0) { + auto* twinEntry = FramebufferImpl::g_backendFramebufferObjects.FindByHandle(record.Fbo); + // P4a decline-site F6: S - already loud, and it KEPT this shape at the + // verification round: binding nothing is exactly what the pre-handle arm + // does in the same situation, so the two arms stay at parity. + if (twinEntry && *twinEntry) { + (*twinEntry)->Bind(target); + } else { + MGLOG_E_ONCE( + "No backend FBO found (maybe not synced) for the current %s FBO record, " + "cannot bind FBO.", + (target == FramebufferTarget::Read ? "READ" : "DRAW")); + } + } else { + MGLOG_D("Binding default framebuffer as %s FBO", + (target == FramebufferTarget::Read ? "READ" : "DRAW")); + // Through the shadow: a raw bind here would leave the shadow claiming + // the previous user FBO, false-skipping its next re-bind. + FramebufferImpl::BindFramebufferId( + target == FramebufferTarget::Draw ? GL_DRAW_FRAMEBUFFER : GL_READ_FRAMEBUFFER, 0); + } + return; + } + } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, §5.4): the pre-handle arm below reads the binding slot, so under a transport + // reaching it is the same refusal SyncCurrentFBO takes. Reaching it means either the + // sync's trust latch is clear - and the sync aborted before it could be - or this bind + // ran with no sync in front of it, which the latch exists to catch. + if (FramebufferRecordArmIsMandatory()) { + RefuseFramebufferBindingSlotRead(); + } +#endif +#endif + + auto& slot = GetFramebufferBindingSlotChecked(target); // No fast path on the binding slot's version. It is a 16-bit counter that only // ForceBindCurrentFBO ever stamps here, so the comparison was against an arbitrarily old // snapshot and any later slot version that happened to land on it - one wrap of the @@ -2827,13 +5934,33 @@ namespace MobileGL::MG_Backend::DirectGLES { // and the twin memo replaces even that with an array probe on the steady path. const auto& currentFBO = slot.GetBoundObject(); if (currentFBO && currentFBO != MG_Impl::GLImpl::FramebufferImpl::pDefaultFramebufferInfo->defaultFBO) { - FramebufferImpl::BackendFramebufferObject* twin = g_fboTwinLookupMemo.Lookup(currentFBO); - if (!twin) { - auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); - if (backendFBOSlot && *backendFBOSlot) { - twin = backendFBOSlot->get(); - g_fboTwinLookupMemo.Store(currentFBO, twin); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside + // the scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + FramebufferImpl::BackendFramebufferObject* twin = nullptr; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // Not "slot": the enclosing scope's `slot` is the framebuffer BINDING slot, + // and this one is the twin table's entry. + auto* twinEntry = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (twinEntry && *twinEntry) { + twin = twinEntry->get(); + } + } else +#endif + { +#if MOBILEGL_PIPE_LEGACY_MEMOS + twin = g_fboTwinLookupMemo.Lookup(currentFBO); + if (!twin) { + auto* backendFBOSlot = FramebufferImpl::g_backendFramebufferObjects.Find(currentFBO.get()); + if (backendFBOSlot && *backendFBOSlot) { + twin = backendFBOSlot->get(); + g_fboTwinLookupMemo.Store(currentFBO, twin); + } } +#endif } if (twin) { twin->Bind(target); @@ -2884,26 +6011,159 @@ namespace MobileGL::MG_Backend::DirectGLES { backendObj->Bind(target); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.2/§3.3): the handle-keyed form of SyncAndBindFramebufferObject. + // With an active transport a framebuffer reaches the server as the handle a record + // carried - the named blit's ReadFbo/DrawFbo, or the applier's own bound handle for a + // binding restore - and the client's binding slots and slot allocator are never probed + // (T2/T3). The twin is resolved (or minted) BY HANDLE; its sync is keyed on THIS handle's + // applier record through m_pushedSyncHandle; and the frontend object the sync body still + // walks comes from the table's own state note (the object-class channel P3b/P4b retires), + // never from a client binding slot. A twin with no noted object was never synced through + // any binding - the DSA-on-a-never-bound-framebuffer case, which the split path already + // refuses for the named clears - and is loud rather than silently unconfigured. + void SyncAndBindFramebufferByHandle(MG_Pipe::MGPipeHandle fbo, FramebufferTarget target, + Bool forceSync = false) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + if (MG_Pipe::MGPipeHandleIsNull(fbo) || fbo == MG_Pipe::kMGPipeDefaultFramebuffer) { + // Same reset as the object form's default branch, for its reason. + if (target == FramebufferTarget::Draw) { + FramebufferImpl::g_alphaWidenedDrawBufferMask = 0; + FramebufferImpl::g_integerColorDrawBufferMask = 0; + } + FramebufferImpl::BindFramebufferId( + target == FramebufferTarget::Draw ? GL_DRAW_FRAMEBUFFER : GL_READ_FRAMEBUFFER, 0); + return; + } + + auto& registry = FramebufferImpl::g_backendFramebufferObjects; + auto* twinSlot = registry.GetOrCreateByHandle(fbo); + if (twinSlot == nullptr) { + MGLOG_E_ONCE("MGPipe: framebuffer handle {%u, %u} is refused by the twin slot table " + "(live generation %u); nothing is bound for the %s target", + fbo.Slot, fbo.Gen, registry.LiveGenAt(fbo.Slot), + target == FramebufferTarget::Read ? "READ" : "DRAW"); + return; + } + auto& backendObj = *twinSlot; + if (!backendObj) { + backendObj = MakeShared(); + } + backendObj->m_pushedSyncHandle = fbo; + // P5e (fb, CONTRACT-P5E.md §5.4): THE STATE NOTE IS GONE. The paragraph above described + // a handle arm that still had to find "the frontend object the sync body walks", and + // its failure mode - a framebuffer named by a DSA entry point that was never bound, so + // no note exists and the twin is bound unconfigured - was the last consequence of that + // detour. SyncToBackendByHandle configures the twin from the RECORD, which every named + // framebuffer has by construction (a Named record precedes every DSA site, ID-19), so + // the never-bound case is now configured correctly rather than named and refused. + if (forceSync) { + backendObj->InvalidateSyncedState(); + } + backendObj->SyncToBackendByHandle(fbo, target); + backendObj->Bind(target); + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + void ForceBindCurrentFBO(FramebufferTarget target) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - auto& slot = GetFramebufferBindingSlotFast(target); - const auto& fbo = slot.GetBoundObject(); - SyncAndBindFramebufferObject(fbo, target); - FramebufferImpl::g_fboSyncedSlotVersions[(SizeT)target] = slot.GetVersion(); - FramebufferImpl::g_fboSyncedObjectVersions[(SizeT)target] = fbo ? fbo->GetObjectVersion() : 0; - FramebufferImpl::g_fboSyncedObjects[(SizeT)target] = fbo.get(); - FramebufferImpl::g_fboSyncedBackendIdGenerations[(SizeT)target] = - FramebufferImpl::g_attachmentBackendIdGeneration; +#if MOBILEGL_PIPE_PUSH && MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd): with an active transport the bound framebuffer's handle is the applier's + // own working state; the object form's registry probe never runs (T2). + // + // P5e (fb, CONTRACT-P5E.md §5.4): AND THE BINDING SLOT IS READ INSIDE THE ELSE, NOT + // BEFORE THE IF. The read and the three stamps below used to straddle this arm - S3's + // finding #7 - so the handle path still touched a BARRIER_PULLED row on both sides of + // the one branch that existed to avoid it. The stamps are the LEGACY memo's + // (g_fboSynced*), and skipping them here is safe for exactly the reason the serial memo + // below is invalidated rather than stamped: nothing on this path consulted the frontend + // trio, so the honest thing is to leave it invalid and pay one extra sync. Under a + // transport the legacy arm is never taken anyway (SyncCurrentFBO refuses first), so the + // memo it feeds has no reader. + if (FramebufferRecordArmIsMandatory()) { + SyncAndBindFramebufferByHandle( + MG_Pipe::MGPipeApplier().BoundFramebuffer[static_cast( + target == FramebufferTarget::Read ? MG_Pipe::MGPipeFramebufferTarget::Read + : MG_Pipe::MGPipeFramebufferTarget::Draw)], + target); + } else +#endif + { + auto& slot = GetFramebufferBindingSlotChecked(target); + const auto& fbo = slot.GetBoundObject(); + SyncAndBindFramebufferObject(fbo, target); + FramebufferImpl::g_fboSyncedSlotVersions[(SizeT)target] = slot.GetVersion(); + FramebufferImpl::g_fboSyncedObjectVersions[(SizeT)target] = fbo ? fbo->GetObjectVersion() : 0; + FramebufferImpl::g_fboSyncedObjects[(SizeT)target] = fbo.get(); + FramebufferImpl::g_fboSyncedBackendIdGenerations[(SizeT)target] = + FramebufferImpl::g_attachmentBackendIdGeneration; + } +#if MOBILEGL_PIPE_PUSH + // P4a decline-site F9 / MINOR-3: THE HANDLE ARM'S MEMO IS INVALIDATED HERE, NOT + // STAMPED, and it is done per target for the reason the memo is kept per target at + // all - this entry point syncs ONE binding and must not say anything about the other. + // + // A stamp would claim "this target reflects the applier's RECORD as of this serial", + // and nothing on this path consulted the record: SyncAndBindFramebufferObject above + // syncs the FRONTEND object out of the binding slot. I could not turn the stamp into + // a wrong-pixel scenario (any edit to the framebuffer moves its ContentHash and hence + // the serial), but it is an invariant break with no demonstrable failure, and the + // honest form costs at most one extra sync on the next draw. + if (FramebufferSubsystemEnabled()) { + FramebufferImpl::InvalidateSyncedFramebufferSerial(target); + } +#endif } static void BindCurrentProgramWithResources( const SharedPtr& currentProgram, - const TextureImpl::DrawTextureSyncKeys& keys); + const TextureImpl::DrawTextureSyncKeys& keys, MG_Pipe::MGPipeHandle programCso); static void BindCurrentTextures(const TextureImpl::DrawTextureSyncKeys& keys, const SharedPtr& currentProgram); + // P5e (pa), CONTRACT-P5E §5.5 / §5.8, ruling ID-81: "IS THERE ANYBODY LEFT WHO NEEDS THE + // FRONTEND PROGRAM OBJECT FOR THIS DRAW", stated once, as a CONJUNCTION - and the conjunction + // is the whole point of the row. + // + // PrepareForDraw / PrepareForCompute hoist ONE GetProgramForDraw() (kimi rows 91 / 92; the + // 68 strict-lane entries of markers GetProgramForDraw@DrawArrays and + // GetProgramForDispatch@DispatchCompute are this one call) and hand it to four callees. The + // call itself is what trips MGP_INPUT_CHECK (PipeInputs.h), so retiring the row means not + // making it - which is only legal once EVERY consumer can be served without the object: + // + // * ProgramHandleArm() answers SyncCurrentProgram / SyncCurrentVertexAttributeValues / + // BindCurrentProgramWithResources. All three have a by-handle arm selected by that very + // test, and none of them reads `currentProgram` on it. + // * TextureImpl::UnitTexturesByHandle() answers BindCurrentTextures, whose LEGACY arm + // reads the program twice: the memo key's four frontend rows, and + // ResolveAndBindUnitTextures' sampledTargetForUnit lambda, which arbitrates aliased + // native targets out of the program's sampler uniforms. Its handle arm returns before + // either. + // + // THE SECOND CONJUNCT IS NOT REDUNDANT. The family bits are independently settable A/Bs + // (0x0ff - bit 7 on, bit 8 off - is supported and must keep running the legacy walk), so + // "the program family is on the handle arm" does NOT imply "no consumer needs the object"; + // those are different statements, and reading the first as the second is precisely the shape + // that cost the phase 137 scenarios (ID-107 / ID-109 / ID-110, fix commit 66621767). Both + // conjuncts reduce to `Transport != Monolith && `, so the push-MONOLITH build is + // false here and keeps the frontend hoist token for token (ruling ID-81), and the pull build + // folds the whole thing to a constant. + // + // The guard is MOBILEGL_BUILD_DISAGGREGATED and not MOBILEGL_PIPE_PUSH because the SECOND + // conjunct only exists there (UnitTexturesByHandle is tx2's, split-only); a push-VERIFY build + // therefore keeps the frontend hoist, which is what the comparator compares against. + inline Bool DrawProgramFromRecords() { +#if MOBILEGL_BUILD_DISAGGREGATED + return ProgramHandleArm() && TextureImpl::UnitTexturesByHandle(); +#else + return false; +#endif + } + void PrepareForDraw(DrawSyncFlags syncBit) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); @@ -2912,24 +6172,74 @@ namespace MobileGL::MG_Backend::DirectGLES { // resolved-buffers memo on the twin), the VAO sync and the draw-time bind // below. Nothing in between can invalidate it — the bound VAO is pinned by // the context, and no step here erases or replaces a live VAO's twin. - const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray(); - VertexArrayImpl::BackendVertexArrayObject* vaoTwin = - currentVAO ? VertexArrayImpl::ResolveVaoTwin(currentVAO) : nullptr; - // Early config-version read: see the note on SyncNeccessaryBuffers - issuing - // the (cold-line) load here overlaps its miss with the resolves below. - const Uint32 vaoConfigVersion = currentVAO ? currentVAO->GetConfigVersion() : 0; + // + // P5e (vi), CONTRACT-P5E §4.2 / §5.1: under a transport the twin is resolved from + // st.BoundVertexElements - THE SAME HANDLE the frontend probe used to mint, since + // EmitVertexElements acquires it off the VAO's own lifetime id - and neither the bound + // VAO row nor the configuration version is read at all. `currentVAO` stays null on that + // arm, which is what the two callees' record arms expect; the null twin case (no + // vertex-elements CSO bound) reaches the same BindBackendVAOId(0) below that a null + // frontend VAO always did. + const Bool vertexInputFromRecords = BufferImpl::VertexInputReadsRecords(); + const SharedPtr noFrontendVao; + const auto& currentVAO = vertexInputFromRecords ? noFrontendVao : MGB_CTX->GetBoundVertexArray(); + VertexArrayImpl::BackendVertexArrayObject* vaoTwin = nullptr; + Uint32 vaoConfigVersion = 0; +#if MOBILEGL_BUILD_DISAGGREGATED + if (vertexInputFromRecords) { + vaoTwin = VertexArrayImpl::ResolveVaoTwin(MG_Pipe::MGPipeApplier().BoundVertexElements); + } else +#endif + { + vaoTwin = currentVAO ? VertexArrayImpl::ResolveVaoTwin(currentVAO) : nullptr; + // Early config-version read: see the note on SyncNeccessaryBuffers - issuing + // the (cold-line) load here overlaps its miss with the resolves below. DEAD on the + // record arm: SyncNeccessaryBuffers only reads it in the legacy branch. + vaoConfigVersion = currentVAO ? currentVAO->GetConfigVersion() : 0; + } // One program resolve and one texture-key capture serve the whole draw, for // the same reason the twin resolve does: only frontend GL entry points move // either, and none can run inside this preparation. GetProgramForDraw is a // cross-TU call with a guarded static inside - repeating it per stage showed // up in draw-loop profiles. - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); + // + // P5e (pa), kimi row 91 / S4 1.1: AND ON THE RECORD ARM IT IS NOT MADE AT ALL. The row + // this package retires is this single call - it is what MGP_INPUT_CHECK trips on, once + // per draw, and it is 61 of the strict lane's 109 red entries. The arm is + // DrawProgramFromRecords(), which names every consumer of the value rather than this + // family alone; `currentProgram` stays null on it and the four callees below take their + // own handle arms, none of which consults it. The null twin case (no program bound) + // reaches the same "nothing to bind" answers a null frontend program always did. + const SharedPtr noFrontendProgram; + const Bool programFromRecords = DrawProgramFromRecords(); + const auto& currentProgram = programFromRecords ? noFrontendProgram : MGB_CTX->GetProgramForDraw(); + if (MG_Util::PipeStats::Enabled()) { + // THE per-draw denominator for Espryt, plus this function's own two accessor + // calls (the VAO and the draw program). Everything the callees below read is + // counted by the callees that are instrumented; the rest is not counted (see + // the inventory in PipeStats.cpp). + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::Draws, 1); + // ZERO on the record arm, one per row still pulled otherwise: P5e (vi) retired the + // bound-VAO accessor call and P5e (pa) the draw-program one, and the ledger has to + // say so or the phase's own "accessor calls per draw" number would keep counting + // reads that no longer happen. Two independent bits, so it is a sum and not a + // three-way pick. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, + (vertexInputFromRecords ? 0 : 1) + (programFromRecords ? 0 : 1)); + } const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys(); BufferImpl::SyncNeccessaryBuffers(currentVAO, vaoTwin, vaoConfigVersion, syncBit & DrawSyncBit::IndexBuffer, syncBit & DrawSyncBit::IndirectBuffer); - VertexArrayImpl::SyncCurrentVAO(currentVAO, vaoTwin); +#if MOBILEGL_BUILD_DISAGGREGATED + if (vertexInputFromRecords) { + VertexArrayImpl::SyncCurrentVAOFromRecords(vaoTwin); + } else +#endif + { + VertexArrayImpl::SyncCurrentVAO(currentVAO, vaoTwin); + } TextureImpl::SyncNeccessaryTextures(textureKeys); // A draw reads and writes through its image units too, so the unit bindings have to be // as current as the sampled ones. Gated (see the sweep): a program with no image binding @@ -2940,7 +6250,20 @@ namespace MobileGL::MG_Backend::DirectGLES { // stores into a buffer texture from the FRAGMENT stage, not from a dispatch. TextureImpl::MarkWritableImageBufferTexturesGpuWritten(); FramebufferImpl::SyncCurrentFBO(); - PrgramImpl::SyncCurrentProgram(currentProgram); +#if MOBILEGL_PIPE_PUSH + // P5e (pg), kimi row 91 / S4 1.1: the SharedPtr pull above is the row this family + // retires. On the handle arm the program is MGPipeApplier().DrawProgram - a handle the + // set_draw_program record carried - and `currentProgram` is not consulted for the sync + // at all; the remaining uses of it below are the two callees' own, and both have a + // handle arm of their own. P5e (pa) finished it: the pull itself is gone on that arm and + // `currentProgram` is a null SharedPtr there, which this branch never reaches for. + if (ProgramHandleArm()) { + PrgramImpl::SyncCurrentProgramByHandle(MG_Pipe::MGPipeApplier().DrawProgram, true); + } else +#endif + { + PrgramImpl::SyncCurrentProgram(currentProgram); + } RenderStateImpl::SyncRenderState(); BindCurrentFBO(FramebufferTarget::Draw); @@ -2956,10 +6279,27 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - VertexArrayImpl::SyncCurrentVertexAttributeValues(vaoTwin, currentProgram); +#if MOBILEGL_PIPE_PUSH + // P5e (pa): the third consumer, on ITS OWN arm and not on the hoist's. The hoist above is + // a conjunction over four callees; this one is served by the record whenever the PROGRAM + // family is on the handle arm, which includes the mixed A/B where the texture bits are + // off and `currentProgram` above is therefore still a real object. + if (ProgramHandleArm()) { + VertexArrayImpl::SyncCurrentVertexAttributeValues(vaoTwin, MG_Pipe::MGPipeApplier().DrawProgram); + } else +#endif + { + VertexArrayImpl::SyncCurrentVertexAttributeValues(vaoTwin, currentProgram); + } BindCurrentTextures(textureKeys, currentProgram); - BindCurrentProgramWithResources(currentProgram, textureKeys); + BindCurrentProgramWithResources(currentProgram, textureKeys, +#if MOBILEGL_PIPE_PUSH + MG_Pipe::MGPipeApplier().DrawProgram +#else + MG_Pipe::kMGPipeNullHandle +#endif + ); // Last: opening the capture span needs the program current and the capture // buffers bound, and ES rejects most binding changes once it is open. @@ -2970,15 +6310,102 @@ namespace MobileGL::MG_Backend::DirectGLES { // false when the resolution could not be completed from the state it read - a bound // texture that has no backend object yet is skipped, and a later draw would bind it // without any of the memo keys below moving - so the caller must not memoise it. + // + // P5e (pa): `currentProgram` IS THE LEGACY WALK'S PARAMETER and is null under + // TextureImpl::UnitTexturesByHandle(), where the by-handle pass below returns before the + // only reader of it (sampledTargetForUnit) is even declared. The caller drops the object + // only once that same test holds, so this is the arm speaking and not a pointer test. static Bool ResolveAndBindUnitTextures(const SharedPtr& currentProgram, Int maxTouchedUnit) { #ifdef TRACY_ENABLE ZoneScopedNC("ResolveAndBindUnitTextures", TRACY_ZONECOLOR_BACKEND); #endif Bool fullyResolved = true; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.2. ONE PASS OVER THE SAMPLER-VIEW WINDOW, AND THEN AN + // UNBIND OF EVERY TARGET THE WINDOW DID NOT CLAIM. + // + // The three-pass slot walk below exists to arbitrate: desktop 1D/1D-array targets alias + // ES 2D/2D-array, a unit can hold a real texture on one of an aliased pair and a default + // object on the other, and two REAL textures can want one native target - which the + // program's sampler types decide. THE CLIENT HAS ALREADY DECIDED ALL OF IT + // (SamplerEmit.h's per-unit resolution is keyed on exactly those sampler types), so the + // arbiter dies here rather than being re-implemented over records: one texture per unit, + // named by the record, with its frontend target in the view CSO. + // + // The unbind half keeps its shape and changes its driver: instead of walking the frontend + // unit's binding slots it walks the fixed TextureTarget enum against g_boundTexturesCache, + // which is SERVER memory and is the shadow every redundant-bind filter in this backend + // already trusts. That is what makes "the window did not claim it" mean "unbind it" - the + // same statement the pre-handle pass makes with `!boundBackendTargets[...]`. + if (TextureImpl::UnitTexturesByHandle()) { + const auto& st = MG_Pipe::MGPipeApplier(); + const Uint32 windowEnd = st.SamplerViewStart + st.SamplerViewCount; + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + const Uint32 index = static_cast(unit); + Array boundBackendTargets{}; + if (index >= st.SamplerViewStart && index < windowEnd && index < st.BoundSamplerViews.size()) { + const auto& view = st.BoundSamplerViews[index]; + if (!MG_Pipe::MGPipeHandleIsNull(view.Texture)) { + // The TARGET is the view CSO's, which is the frontend target the client + // resolved this unit against - not a second derivation from the texture's + // descriptor, which would be a second authority for one value. + const auto* viewRecord = PipeSamplerViewRecordForHandle(view.View); + const auto target = viewRecord != nullptr + ? static_cast(viewRecord->View.Target) + : TextureTarget::Unknown; + if (viewRecord == nullptr) { + MGLOG_E_ONCE("MGPipe: unit %d's sampler view {%u, %u} has no applier record, " + "so the target to bind texture {%u, %u} at is unknown; the unit " + "keeps what it holds", + static_cast(unit), view.View.Slot, view.View.Gen, + view.Texture.Slot, view.Texture.Gen); + fullyResolved = false; + } else if (!TextureImpl::IsSupportedTextureTarget(target)) { + MGLOG_D(" Texture target %s is not supported, skipping.", + MG_Util::ConvertTextureTargetToString(target).c_str()); + } else if (auto* twin = TextureImpl::ResolveTextureTwin(view.Texture)) { + const GLenum targetGL = TextureImpl::ConvertTextureTargetToBackendGLEnum(target); + twin->Bind(targetGL, unit); + boundBackendTargets[static_cast( + TextureImpl::MapToBackendTextureTarget(target))] = true; + } else { + // No twin yet: the sync pass that would have built it declined and + // named why. Not memoisable - a later draw would bind it without any + // key moving. + fullyResolved = false; + } + } + } + // The backend half of glBindTexture(..., 0) for every native target this unit no + // longer claims, driven by the server's own binding shadow. + Array visitedBackendTargets{}; + for (SizeT t = 0; t < (SizeT)TextureTarget::TextureTargetCount; ++t) { + const auto target = static_cast(t); + if (!TextureImpl::IsSupportedTextureTarget(target)) continue; + const auto backendTargetIndex = + static_cast(TextureImpl::MapToBackendTextureTarget(target)); + if (visitedBackendTargets[backendTargetIndex]) continue; + visitedBackendTargets[backendTargetIndex] = true; + if (boundBackendTargets[backendTargetIndex]) continue; + if (TextureImpl::g_boundTexturesCache[static_cast(unit)][backendTargetIndex] == nullptr) { + continue; + } + TextureImpl::UnbindTexture(unit, TextureImpl::ConvertTextureTargetToBackendGLEnum(target)); + } + } + return fullyResolved; + } +#endif // Frontend target the current program samples at a given unit; resolves an // aliased native binding when two real textures compete for it (see below). // Only consulted on a conflict, so the ordinary unit costs nothing. + // + // P5e (pa): THE LAST PROGRAM READ ON THIS PATH, and it stays where it is because the + // path it is on is the one the handle arm above already returned from. It is also the + // reason PrepareForDraw's hoist tests UnitTexturesByHandle() as well as + // ProgramHandleArm(): with the texture bits off and the program bits on, this lambda + // still runs and still needs a real object. const auto sampledTargetForUnit = [¤tProgram](Int unit) { if (!currentProgram || !currentProgram->GetLinkStatus()) { return TextureTarget::Unknown; @@ -2995,7 +6422,7 @@ namespace MobileGL::MG_Backend::DirectGLES { }; for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); Array boundBackendTargets{}; Array claimedByFrontendTarget{}; claimedByFrontendTarget.fill(TextureTarget::Unknown); @@ -3057,6 +6484,11 @@ namespace MobileGL::MG_Backend::DirectGLES { } // Bind texture object +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt + // inside the scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); if (!backendTextureSlot || !*backendTextureSlot) { fullyResolved = false; @@ -3103,7 +6535,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // pass creates it later in the same draw), and a cached miss would keep skipping the // bind after it appears. struct UnitSamplerLookupMemo { +#if MOBILEGL_PIPE_PUSH + // The {slot, gen} of the frontend sampler this row was resolved for. It replaces the + // weak_ptr and its owner compare: a stale row cannot match, because the successor of a + // freed sampler is handed the same slot only with a higher Gen. + MG_Pipe::MGPipeHandle frontendHandle = MG_Pipe::kMGPipeNullHandle; +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS WeakPtr frontend{}; +#endif SamplerImpl::BackendSamplerObject* backend = nullptr; }; static Array @@ -3111,7 +6551,50 @@ namespace MobileGL::MG_Backend::DirectGLES { static SamplerImpl::BackendSamplerObject* ResolveUnitSamplerBackend( Int unit, const SharedPtr& samplerObject) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §4.2's last paragraph: THE IDENTITY SAMPLER FAMILY IS DELETED + // UNDER A TRANSPORT AND BECOMES A NAMED REFUSAL. + // + // This function is the apply-thread MINT for a unit the record set does not cover: it + // probes the client allocator with HandleOf(samplerObject), keeps a WeakPtr to a frontend + // sampler in a per-unit memo, and its caller in the program pass creates a twin off the + // frontend object when the lookup misses. Every one of those is a rule-F violation, and + // the window rule (§5.3) makes the case it exists for UNREPRESENTABLE: a unit whose + // BoundSamplerStates[u] is null while the frontend held a sampler is a MISSING RECORD, + // not a unit to be served from the client's object. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"BindSamplerStates.Count\"} - unit %d holds a " + "frontend sampler object that the applied bind_sampler_states window does not " + "describe. Minting a twin for it would probe the client's allocator from the " + "apply thread; CONTRACT-P5E.md §4.2 deletes the identity sampler family under a " + "transport and §5.3 makes the missing entry unrepresentable", + static_cast(unit)); + std::abort(); + } + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside the + // scope - the refusal above is what retires it under a transport. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif auto& memo = g_unitSamplerLookupMemos[static_cast(unit)]; +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + const MG_Pipe::MGPipeHandle handle = + SamplerImpl::g_backendSamplerObjects.HandleOf(samplerObject.get()); + if (memo.backend && !MG_Pipe::MGPipeHandleIsNull(handle) && memo.frontendHandle == handle) { + return memo.backend; + } + auto* slot = SamplerImpl::g_backendSamplerObjects.FindByHandle(handle); + if (slot && *slot) { + // A MISS is still never cached: the twin may not exist yet when the unit pass + // runs, because the program pass creates it later in the same draw. + memo.frontendHandle = handle; + memo.backend = slot->get(); + return memo.backend; + } + return nullptr; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (memo.backend && OwnerEquals(memo.frontend, samplerObject)) { return memo.backend; } @@ -3121,6 +6604,7 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.backend = backendSamplerSlot->get(); return memo.backend; } +#endif return nullptr; } @@ -3158,17 +6642,126 @@ namespace MobileGL::MG_Backend::DirectGLES { return; } g_unitSamplerWalkValid = false; - for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { - const auto& samplerObject = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); - if (samplerObject) { - if (auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject)) { - backendSampler->Bind(unit); +#if MOBILEGL_PIPE_PUSH + Bool walkedFromRecords = false; + // P4a e4 (D-G2). THE WALK ITSELF READS THE APPLIER'S PER-UNIT HANDLE ARRAY. + // + // bind_sampler_states is the resolved answer to exactly the question this loop asks - + // which sampler CSO does each touched unit carry - so the handle IS the lookup: no + // GetTextureUnitObject per unit, no lifetime-id probe, and no UnitSamplerLookupMemo + // row to keep, because FindByHandle is already one array index. The memo below stays + // for the arm that still resolves through a frontend object. + // + // A NULL HANDLE MEANS "this unit has no sampler object", which is the record's way of + // saying what the frontend's null SharedPtr says, and it unbinds - the texture's own + // built-in sampler then applies, exactly as today. A live handle resolves to THE CSO's + // OWN TWIN (P4a fable seam F-4, SamplerImpl::ResolveSamplerCsoTwin): the sentence that + // stood here - "a handle whose twin does not exist yet is left alone ... the program + // pass creates the twin later in the same draw" - described a lookup that could never + // hit, because the handle is content-addressed and the registry's twins were minted off + // lifetime ids, so this arm bound nothing on every draw and only the pre-handle program + // pass ever put a glBindSampler'd object on the driver. The program pass now binds the + // same CSO twin for the units it samples, which is what stops the two arms ping-ponging + // a unit between two driver samplers. + // + // Declines - falls through to the frontend walk - until the set has arrived, and the + // COUNT says so rather than the serial (MGPipeApplierReset advances serials whether or + // not anything was emitted). + // P4a decline-site S1: M - the mask says this family is not switched on, and it IS + // D's SamplerSubsystemEnabled() now. Silent, confirmed at the verification round. + if (SamplerSubsystemEnabled()) { + const auto& st = MG_Pipe::MGPipeApplier(); + // P4a decline-site S2: S - FLIPPED AT THE VERIFICATION ROUND to loud-once, then + // the frontend walk. Bit 11 is on (the enclosing SamplerSubsystemEnabled()) and + // package C has landed, so a draw that touches texture units with no + // bind_sampler_states ever applied is a seam defect. The frontend walk still + // runs: it answers the same question correctly, and what this line exists to stop + // is the SILENT permanent fallback, not the fallback. + // NARROWED BY THE FIRST REAL-PATH RUN, exactly as T2 was and for the same + // reason: maxTouchedUnit < 0 is a draw that touches no texture unit, for which an + // empty window is the correct emission. All 161 hits of the unnarrowed line were + // that shape - the log said `units 0..-1` on every one of them. + if (st.SamplerStateCount == 0 && maxTouchedUnit >= 0) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.3: the same refusal CurrentUnitBindingsEpoch makes - + // the frontend walk below reads GetTextureUnitObject per unit, which an active + // transport may not do for a record whose client has moved on. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"BindSamplerStates.Count\"} - a draw " + "touches units 0..%d and no bind_sampler_states has ever been applied " + "while the sampler subsystem bit is set; the pre-handle sampler walk " + "reads client memory and is refused under an active transport " + "(CONTRACT-P5E.md §5.3)", + static_cast(maxTouchedUnit)); + std::abort(); + } +#endif + MGLOG_E_ONCE("A sampler record does not describe the binding it names: a draw touches " + "units 0..%d and no bind_sampler_states has ever been applied while the " + "sampler subsystem bit is set; running the pre-handle sampler walk.", + static_cast(maxTouchedUnit)); + } + if (st.SamplerStateCount != 0) { + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + const Uint32 index = static_cast(unit); + // P4a decline-site S3: the OUT-OF-WINDOW UNBIND (MAJOR-2, fixed here); no + // flip remains at the verification round, only the A8 measurement below. + // + // A UNIT THE RECORD DOES NOT DESCRIBE IS UNBOUND, NOT SKIPPED. The var-tail + // window IS the bound for RECORD RETENTION - entries outside it are not + // cleared in the applier - but this loop is not reading records, it is + // writing DRIVER state, and for a driver binding "no record" means "no + // sampler object", which is exactly what the pre-handle arm's + // `else { UnbindSampler(unit); }` below says and why it was written: a + // sampler object left on the unit by an earlier draw keeps being applied, + // and on a multisample texture - which takes no sampler object at all - the + // draw is rejected outright. `continue` here reintroduced that defect on + // the new arm for every unit below SamplerStateStart or above the window, + // which is the same smaller-window direction DEV-3 refused. + // + // A8 again: nothing in the contract pins the window to cover + // [0, maxTouchedUnit]. If package C documents that it does, this branch + // becomes unreachable and can be asserted rather than executed; until then + // it is load-bearing, and the verification round measures + // (SamplerStateStart, SamplerStateCount) against maxTouchedUnit to say so. + if (index < st.SamplerStateStart || index - st.SamplerStateStart >= st.SamplerStateCount) { + SamplerImpl::UnbindSampler(unit); + continue; + } + const MG_Pipe::MGPipeHandle sampler = st.BoundSamplerStates[index]; + if (MG_Pipe::MGPipeHandleIsNull(sampler)) { + SamplerImpl::UnbindSampler(unit); + continue; + } + // P4a decline-site S4, RETIRED at the fable seam round (F-4): what stood here + // was `g_backendSamplerObjects.FindByHandle(sampler)` - an identity-keyed + // table asked for a content-addressed handle, a miss by construction on + // every draw - with a comment that read the miss as a within-draw ordering + // fact. The twin is the CSO's own now; a null answer has already named its + // reason (no record, or a slot that cannot be adopted) and leaves the unit + // as it is, which is the one decline this arm still has. + if (auto* twin = SamplerImpl::ResolveSamplerCsoTwin(sampler)) { + twin->Bind(unit); + } + } + walkedFromRecords = true; + } + } + if (!walkedFromRecords) +#endif + { + for (Int unit = 0; unit <= maxTouchedUnit; ++unit) { + const auto& samplerObject = MGB_CTX->GetTextureUnitObject(unit).GetSamplerObject(); + if (samplerObject) { + if (auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject)) { + backendSampler->Bind(unit); + } + } else { + // Symmetric with the bind above: a sampler object left on the unit by an + // earlier draw keeps being applied, and on a multisample texture - which + // takes no sampler object at all - the draw is rejected outright. + SamplerImpl::UnbindSampler(unit); } - } else { - // Symmetric with the bind above: a sampler object left on the unit by an earlier - // draw keeps being applied, and on a multisample texture - which takes no sampler - // object at all - the draw is rejected outright. - SamplerImpl::UnbindSampler(unit); } } g_unitSamplerWalkContextId = keys.contextId; @@ -3226,6 +6819,33 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 programBackendStateVersion = 0; Bool programLinked = false; Uint contextGeneration = 0; + // P5e (pg) -> tx2, ruling 6 / ID-86: THE PROGRAM HALF OF THIS KEY HAS TO GO BY HANDLE, + // and this struct is tx2's line, so pg states the shape rather than writing it. The four + // program fields above are read off the frontend ProgramObject on EVERY DRAW, memo hit + // included (kimi row 100, the worst row of the program family). What replaces them on + // the handle arm is {DrawProgram.Slot, DrawProgram.Gen, ShaderCso record Serial, + // BindingsSerial}: Serial moves on every applied create_shader_state (how a relink + // travels) and BindingsSerial on every applied set_program_bindings (how a glUniform1i + // on a sampler travels). Both are needed - ruling 6's "both" - because a relink with + // UNCHANGED texture resolution still hands the twin new unit assignments. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.3 / ruling 6 (ID-86): THE KEY IS BOTH HALVES. + // + // S2 argued SamplerViewsSerial alone - it already mixes the program-resolved opaque units, + // so a resolution that moved moves it - and S4 argued the program's rows. The integrator + // ruled BOTH, for the case neither covers alone: a RELINK whose resolution happens to be + // unchanged still needs the twin's new unit assignments, and SamplerViewsSerial does not + // move for it (the emitter's content hash is over the resolved set, which did not change). + // + // The frontend program rows above (pointer, lifetime id, backend-state version, link + // status) stay for the monolith arm and are simply not read on this one. + MG_Pipe::MGPipeHandle drawProgram = MG_Pipe::kMGPipeNullHandle; + Uint64 shaderCsoSerial = 0; + Uint64 bindingsSerial = 0; + Uint64 samplerViewsSerial = 0; + Uint64 contextSerial = 0; + Bool byHandle = false; +#endif decltype(TextureImpl::g_boundTexturesCache) boundTextures{}; }; // Small per-PROGRAM memo set, not one global: the program is part of the key @@ -3246,6 +6866,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // sample whatever texture the last sync left behind (e.g. Flywheel's depth // pyramid downsample reading a stale unit-0 binding instead of the depth // attachment). + // + // P5e (pa): `currentProgram` IS NULL ON THE HANDLE ARM, and that is a statement about the + // caller's arm rather than something this body may test for. Every read of it below sits on + // the `byHandle == false` side of a branch whose condition is TextureImpl::UnitTexturesByHandle() + // - the memo's entry selection, the four frontend key rows, and ResolveAndBindUnitTextures' + // sampledTargetForUnit lambda, which its own handle arm returns before reaching. The hoist in + // PrepareForDraw only drops the object once THAT test holds too (DrawProgramFromRecords), so + // this parameter is null exactly where nothing consults it. static void BindCurrentTextures(const TextureImpl::DrawTextureSyncKeys& keys, const SharedPtr& currentProgram) { #ifdef TRACY_ENABLE @@ -3254,13 +6882,53 @@ namespace MobileGL::MG_Backend::DirectGLES { // Units past the frontend's high-water mark have provably-empty slots. const Int maxTouchedUnit = keys.maxTouchedUnit; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2) / ruling 6: on the handle arm the key is the applier's rows and nothing + // frontend - INCLUDING the entry selection, which used the frontend program's address. + // maxTouchedUnit stays in it because the unbind sweep this memo replays is bounded by it. + const Bool byHandle = TextureImpl::UnitTexturesByHandle(); + MG_Pipe::MGPipeHandle drawProgram = MG_Pipe::kMGPipeNullHandle; + Uint64 shaderCsoSerial = 0; + Uint64 bindingsSerial = 0; + Uint64 samplerViewsSerial = 0; + Uint64 contextSerial = 0; + if (byHandle) { + const auto& st = MG_Pipe::MGPipeApplier(); + drawProgram = st.DrawProgram; + samplerViewsSerial = st.SamplerViewsSerial; + contextSerial = st.ContextSerial; + if (const auto* cso = PipeShaderCsoRecordForHandle(drawProgram)) { + shaderCsoSerial = cso->Serial; + bindingsSerial = cso->BindingsSerial; + } + } +#endif // Entry selection by program pointer; a missing program takes the round-robin // victim. WHICH entry is used is only a performance choice - correctness sits // entirely in the full key + shadow compare below, unchanged from the single // memo this set replaces. - const void* programKey = static_cast(currentProgram.get()); + // + // P5e (pa): NOT EVEN THE POINTER, on the handle arm. The by-handle branch below selects + // and keys on {DrawProgram, ShaderCso.Serial, BindingsSerial} (ruling ID-86) and never + // looks at this value, but it was still being loaded out of the caller's SharedPtr every + // draw - and once the caller stops holding one there is nothing there to load. + const void* programKey = +#if MOBILEGL_BUILD_DISAGGREGATED + byHandle ? nullptr : +#endif + static_cast(currentProgram.get()); ResolvedTextureBindingMemo* memoSlot = nullptr; for (auto& candidate : g_resolvedTextureBindingMemos) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (byHandle) { + if (candidate.valid && candidate.byHandle && candidate.drawProgram.Slot == drawProgram.Slot && + candidate.drawProgram.Gen == drawProgram.Gen) { + memoSlot = &candidate; + break; + } + continue; + } +#endif if (candidate.valid && candidate.program == programKey) { memoSlot = &candidate; break; @@ -3275,7 +6943,15 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT shadowBytes = static_cast(maxTouchedUnit + 1) * sizeof(TextureImpl::g_boundTexturesCache[0]); const Uint64 unitBindingsEpoch = keys.unitBindingsEpoch; - const Bool keysMatch = memo.valid && memo.glContextId == keys.contextId && + // P5e (ID-124): the legacy key is a LAZY LAMBDA, not an eagerly-computed Bool. It was + // the else-arm of a ternary whose declaration sat inside `#if MOBILEGL_BUILD_DISAGGREGATED` + // while the arm itself and every use of `keysMatch` sat outside it, so neither + // non-disaggregated flavour compiled. Hoisting it to an eager Bool would have been + // WORSE than the build break: it reads `currentProgram`, which is null on the handle + // arm, so the laziness the ternary gave for free is load-bearing (ID-81 / ID-110 - + // the arm decides, and the other arm's reads must not happen at all). + const auto legacyKeysMatch = [&]() -> Bool { + return (memo.valid && memo.glContextId == keys.contextId && memo.maxTouchedUnit == maxTouchedUnit && memo.unitBindingsEpoch == unitBindingsEpoch && memo.samplingResolutionGeneration == keys.samplingGeneration && @@ -3284,7 +6960,19 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.programBackendStateVersion == (currentProgram ? currentProgram->GetBackendStateVersion() : 0) && memo.programLinked == (currentProgram && currentProgram->GetLinkStatus()) && - memo.contextGeneration == g_backendContextGeneration; + memo.contextGeneration == g_backendContextGeneration); + }; +#if MOBILEGL_BUILD_DISAGGREGATED + const Bool keysMatch = + byHandle ? (memo.valid && memo.byHandle && memo.maxTouchedUnit == maxTouchedUnit && + memo.contextSerial == contextSerial && memo.samplerViewsSerial == samplerViewsSerial && + memo.drawProgram.Slot == drawProgram.Slot && memo.drawProgram.Gen == drawProgram.Gen && + memo.shaderCsoSerial == shaderCsoSerial && memo.bindingsSerial == bindingsSerial && + memo.contextGeneration == g_backendContextGeneration) + : legacyKeysMatch(); +#else + const Bool keysMatch = legacyKeysMatch(); +#endif // Short-circuited: the shadow compare is only meaningful once the key (and with it the // snapshotted row count) matches. if (!keysMatch || std::memcmp(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), @@ -3296,10 +6984,32 @@ namespace MobileGL::MG_Backend::DirectGLES { memo.unitBindingsEpoch = unitBindingsEpoch; memo.samplingResolutionGeneration = keys.samplingGeneration; memo.program = programKey; - memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0; - memo.programBackendStateVersion = currentProgram ? currentProgram->GetBackendStateVersion() : 0; - memo.programLinked = currentProgram && currentProgram->GetLinkStatus(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (pa): the four frontend program rows belong to the LEGACY key and are not + // written on the handle arm - decided by `byHandle`, which is the arm, and not by + // the object happening to be null, which is only a consequence of it. They are + // zeroed rather than skipped so an entry cannot carry a row nobody wrote. + if (byHandle) { + memo.programLifetimeId = 0; + memo.programBackendStateVersion = 0; + memo.programLinked = false; + } else +#endif + { + memo.programLifetimeId = currentProgram ? currentProgram->GetLifetimeId() : 0; + memo.programBackendStateVersion = + currentProgram ? currentProgram->GetBackendStateVersion() : 0; + memo.programLinked = currentProgram && currentProgram->GetLinkStatus(); + } memo.contextGeneration = g_backendContextGeneration; +#if MOBILEGL_BUILD_DISAGGREGATED + memo.byHandle = byHandle; + memo.drawProgram = drawProgram; + memo.shaderCsoSerial = shaderCsoSerial; + memo.bindingsSerial = bindingsSerial; + memo.samplerViewsSerial = samplerViewsSerial; + memo.contextSerial = contextSerial; +#endif std::memcpy(memo.boundTextures.data(), TextureImpl::g_boundTexturesCache.data(), shadowBytes); memo.valid = true; } @@ -3309,7 +7019,14 @@ namespace MobileGL::MG_Backend::DirectGLES { } void BindCurrentTextures() { - BindCurrentTextures(TextureImpl::CaptureDrawTextureSyncKeys(), MG_State::pGLContext->GetProgramForDraw()); + // P5e (pa): the exported no-argument entry (DirectGLES.h) takes the SAME arm the hoist in + // PrepareForDraw does, and for the same reason: the pull is what trips the input check, so + // an entry point that keeps it would keep the row alive for whichever caller reaches this + // overload next. Today that is SanityTest's unit cases, which run under monolith + // transport and therefore take the frontend arm below unchanged. + const SharedPtr noFrontendProgram; + BindCurrentTextures(TextureImpl::CaptureDrawTextureSyncKeys(), + DrawProgramFromRecords() ? noFrontendProgram : MGB_CTX->GetProgramForDraw()); } // Binds the current program's backend object and re-establishes its per-program @@ -3318,23 +7035,72 @@ namespace MobileGL::MG_Backend::DirectGLES { // association must be rebuilt through the API). Compute dispatches depend on // this as much as draws do — e.g. Flywheel's cull shader reads the // _FlwFrameUniforms block and the _flw_depthPyramid sampler. + // + // P5e (pa): `currentProgram` IS NULL WHENEVER `handleArm` HOLDS. Every read of it in the + // body below is on the `:` side of a `handleArm ? :` pick or inside the `else` of + // `if (handleArm)`, with one pair that is guarded instead - the two `globalConstants ? ... :` + // reads, which the [[noreturn]] refusal a few lines above them makes unreachable on that arm + // (its note says so). The parameter stays for the monolith arm, which is ruling ID-81: that + // build keeps this function's frontend text token for token. static void BindCurrentProgramWithResources( const SharedPtr& currentProgram, - const TextureImpl::DrawTextureSyncKeys& keys) { - if (currentProgram && currentProgram->GetLinkStatus() && currentProgram->GetSpirvStatus()) { + const TextureImpl::DrawTextureSyncKeys& keys, MG_Pipe::MGPipeHandle programCso) { +#if MOBILEGL_PIPE_PUSH + // P5e (pg): THE HANDLE ARM'S GATE, from the record rather than from the object. Hoisted + // out of the `if` below because the whole body's entry condition is "the program linked + // and has SPIR-V", and on this arm both answers are fields of the descriptor + // (Desc.LinkStatus is ID-88's, Desc.SpirvStatus P4a's). + const Bool handleArm = ProgramHandleArm(); + const MG_Pipe::MGPipeShaderCsoRecord* programRecord = nullptr; + if (handleArm) { + programRecord = PrgramImpl::FindShaderCsoRecord(programCso); + if (programRecord != nullptr && + (programRecord->Desc.LinkStatus == 0 || programRecord->Desc.SpirvStatus == 0)) { + programRecord = nullptr; + } + } + const Bool programUsable = + handleArm ? programRecord != nullptr + : (currentProgram && currentProgram->GetLinkStatus() && currentProgram->GetSpirvStatus()); +#else + const Bool programUsable = + currentProgram && currentProgram->GetLinkStatus() && currentProgram->GetSpirvStatus(); +#endif + if (programUsable) { #ifdef TRACY_ENABLE ZoneScopedNC("BindCurrentProgram", TRACY_ZONECOLOR_BACKEND); #endif - // The twin SyncCurrentProgram just resolved for this draw; the registry - // Find only runs if the stash somehow does not match (defensive fallback). - PrgramImpl::BackendProgramObjectImpl* twin = - PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get() - ? PrgramImpl::g_currentDrawBackendProgram - : nullptr; - if (!twin) { - auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(currentProgram.get()); - if (backendProgramSlot) { - twin = backendProgramSlot->get(); + PrgramImpl::BackendProgramObjectImpl* twin = nullptr; +#if MOBILEGL_PIPE_PUSH + if (handleArm) { + // THE STASH, KEYED ON THE HANDLE (CONTRACT-P5E §5.5). SyncCurrentProgramByHandle + // wrote it a few lines ago in the same Prepare; the fallback is the by-handle + // resolver, which is record-first and touches no frontend identity - so unlike + // the frontend fallback below it is not a registry probe at all. + twin = PrgramImpl::g_currentDrawProgramHandle == programCso + ? PrgramImpl::g_currentDrawBackendProgram + : nullptr; + if (!twin) twin = PrgramImpl::ResolveProgramTwin(programCso); + } else +#endif + { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): MONOLITH GLUE ONLY now. The frontend-keyed twin + // resolution below is the fourth of this family's five named scope sites, and + // P5e does not delete it - it stops reaching it under a transport, which is what + // the allocator guard measures. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + // The twin SyncCurrentProgram just resolved for this draw; the registry + // Find only runs if the stash somehow does not match (defensive fallback). + twin = PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get() + ? PrgramImpl::g_currentDrawBackendProgram + : nullptr; + if (!twin) { + auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(currentProgram.get()); + if (backendProgramSlot) { + twin = backendProgramSlot->get(); + } } } if (twin) { @@ -3344,12 +7110,78 @@ namespace MobileGL::MG_Backend::DirectGLES { // Global UBO: block index and binding-point assignment are cached at // link time (CacheResourceLocations); re-upload only when the CPU shadow // actually changed since the last upload for this program. - if (currentProgram->GetUBOSize() > 0 && backendProgram.HasGlobalUboBlock()) { +#if MOBILEGL_PIPE_PUSH + // P5e (pg): the block's SIZE is Desc.GlobalUboSize on the handle arm. It is the + // same number GetUBOSize() answers - the emitter takes it from there - but it is + // the record's copy, which is the point. + const Uint globalUboSize = + handleArm ? programRecord->Desc.GlobalUboSize : currentProgram->GetUBOSize(); +#else + const Uint globalUboSize = currentProgram->GetUBOSize(); +#endif + if (globalUboSize > 0 && backendProgram.HasGlobalUboBlock()) { #ifdef TRACY_ENABLE ZoneScopedNC("UpdateGlobalUBO", TRACY_ZONECOLOR_BACKEND); #endif - const Uint32 uboContentVersion = currentProgram->GetUBOContentVersion(); - const SizeT uboSize = static_cast(currentProgram->GetUBOSize()); +#if MOBILEGL_PIPE_PUSH + // P4a e4 (D-H6): set_global_constants' counterpart. The version and the + // block image come off the ShaderCso record when it carries them; the UBO + // RING BELOW IS UNTOUCHED - it is on the Espryt do-not-touch list, keeps + // its {contentVersion, ringGeneration, frameSerial, offset} allocation + // key, keeps its glBufferSubData fallback and keeps accounting to + // ByteClass::StageUboGlobal. What changes is where the version and the + // bytes come from, and nothing else. + // + // The record covers the DEFAULT UNIFORM BLOCK ONLY. Named UBOs are the + // block right after this one and they are P4b's (dirty bits 15-17), which + // is why nothing here touches them. + const MG_Pipe::MGPipeShaderCsoRecord* const globalConstants = + handleArm ? PrgramImpl::ResolveGlobalConstantsRecordForHandle(programCso, + globalUboSize) + : PrgramImpl::ResolveGlobalConstantsRecord(currentProgram.get()); + // P5e (pg), gap G-C: `MapUBO()` HAS NO RUN-AHEAD ANSWER. It returns + // ProgramObject::globalUboScratch - the live CPU array every glUniform* + // writes into - so under a transport it is a torn read of client memory by + // construction, not a read that happens to race. Every reason + // ResolveGlobalConstantsRecord* can decline is a SEAM DEFECT here (bit 12 + // off, the mis-keyed descriptor, the never-uploaded sentinel, a short block + // image), and each of them has already said so by name one level down; what + // this adds is that on the handle arm there is nothing legal to fall back + // to. CONTRACT-P5E §5.5: Fatal{UnmigratedVerb, "set_global_constants"}, not + // a torn upload. + // + // P5e (pa): AND IT IS ALSO WHAT KEEPS THE TWO `globalConstants ? ... : + // currentProgram->...` READS BELOW OFF THE FRONTEND. std::abort() is + // [[noreturn]], so past this statement `handleArm` implies + // `globalConstants != nullptr` in one step and in this function - not by a + // chain through other files, which is the distinction ID-110 draws. It + // matters now: pa's caller passes a NULL ProgramObject on that arm, so the + // `:` sides are unreachable rather than merely unused. + if (handleArm && globalConstants == nullptr) { + MGLOG_F("MGPipe: Fatal{UnmigratedVerb, \"set_global_constants\"} - the " + "ShaderCso record {%u, %u} does not answer the default uniform " + "block for a draw of a program that declares %u bytes of it, and " + "MapUBO() is the client's live scratch array", + programCso.Slot, programCso.Gen, globalUboSize); + std::abort(); + } +#endif + const Uint32 uboContentVersion = +#if MOBILEGL_PIPE_PUSH + globalConstants ? globalConstants->GlobalConstantsVersion : +#endif + currentProgram->GetUBOContentVersion(); + const SizeT uboSize = static_cast(globalUboSize); + // Substituted at the two upload sites rather than hoisted into a local: + // MapUBO() is called there today and only there, and hoisting it would + // call it on paths that skip the upload entirely. +#if MOBILEGL_PIPE_PUSH +#define MGB_UBO_BYTES \ + (globalConstants ? static_cast(globalConstants->GlobalConstants.data()) \ + : static_cast(currentProgram->MapUBO())) +#else +#define MGB_UBO_BYTES currentProgram->MapUBO() +#endif // Preferred path: write changed contents into a fresh slot of the // shared persistent-mapped ring and bind it as a range. The GPU // never reads bytes the CPU is writing, so the driver has no @@ -3369,7 +7201,11 @@ namespace MobileGL::MG_Backend::DirectGLES { SizeT offset = 0; if (BufferImpl::UboRingAllocate(bindSize, offset)) { std::memcpy(static_cast(BufferImpl::UboRingMappedPtr()) + offset, - currentProgram->MapUBO(), uboSize); + MGB_UBO_BYTES, uboSize); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboGlobal, + static_cast(uboSize)); + } ringSlot = {uboContentVersion, BufferImpl::UboRingGeneration(), frameSerial, offset}; slotValid = true; @@ -3387,14 +7223,20 @@ namespace MobileGL::MG_Backend::DirectGLES { // failed): the original in-place upload. if (backendProgram.GetLastUploadedGlobalUboVersion() != uboContentVersion) { g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, backendProgram.GetBackendGlobalUBOId()); - g_GLESFuncs.glBufferSubData(GL_UNIFORM_BUFFER, 0, currentProgram->GetUBOSize(), - currentProgram->MapUBO()); + g_GLESFuncs.glBufferSubData(GL_UNIFORM_BUFFER, 0, globalUboSize, + MGB_UBO_BYTES); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes( + MG_Util::PipeStats::ByteClass::StageUboGlobal, + static_cast(globalUboSize)); + } g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, 0); backendProgram.SetLastUploadedGlobalUboVersion(uboContentVersion); } BufferImpl::BindBufferBaseCached(GL_UNIFORM_BUFFER, 0, backendProgram.GetBackendGlobalUBOId()); } +#undef MGB_UBO_BYTES } { @@ -3418,9 +7260,82 @@ namespace MobileGL::MG_Backend::DirectGLES { continue; } - // Connect buffer to backend binding point - auto binding = currentProgram->GetUniformBlockBinding(i); - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, binding); + // Connect buffer to backend binding point. + // + // P5e (pg): glUniformBlockBinding MOVES THIS AFTER THE LINK, so it is + // one of the three post-link mutable reflection fields the archive alone + // cannot answer - set_program_bindings' first tail carries it, dense in + // this very index space. The BINDING POINT it names is sb's row (the + // GetBufferBindingPoint below); this line is the boundary S4 R5 named. + auto binding = +#if MOBILEGL_PIPE_PUSH + handleArm ? PrgramImpl::ProgramBlockBindingFromRecord(*programRecord, i) + : +#endif + currentProgram->GetUniformBlockBinding(i); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (sb, §5.6): THE POINT HALF, by record. `binding` above is the + // PROGRAM's block binding and stays exactly where it is (it is package + // pg's row); what moves is the point it indexes. The index space is the + // same one - Start is 0 by contract - so `binding` addresses the + // applier's window directly, and a binding at or above Count means + // "nothing bound", which is what the frontend array's default said too. + if (BufferImpl::BindingPointsComeFromRecords()) { + const MG_Pipe::MGPipeApplierState& st = MG_Pipe::MGPipeApplier(); + const Uint32 windowStart = + st.ShaderBufferStart[MG_Pipe::kMGPipeShaderBufferClassUniform]; + const SizeT windowEnd = + static_cast(windowStart) + + st.ShaderBufferCount[MG_Pipe::kMGPipeShaderBufferClassUniform]; + if (static_cast(binding) < windowStart || + static_cast(binding) >= windowEnd) { + continue; // nothing bound there - the frontend arm's `if (bufferObj)` + } + const MG_Pipe::MGPBufferRange& entry = + st.BoundShaderBuffers[MG_Pipe::kMGPipeShaderBufferClassUniform] + [static_cast(binding)]; + if (MG_Pipe::MGPipeHandleIsNull(entry.Res)) continue; + // The clean probe is the SAME five questions IsBufferDrawClean asks + // the frontend object, asked by handle: the twin's identity, the + // resource record's Serial against the twin's synced one, and the + // pending-work sets - all server-owned (D-A4). The drawCleanEpoch + // short-circuit is unchanged; it was never the point read. + auto* backendResource = BufferImpl::FindBufferResourceForHandle(entry.Res); + if (!backendResource || backendResource->drawCleanEpoch != bufferEpoch) { + if (backendResource && BufferImpl::IsBufferDrawCleanByHandle( + entry.Res, backendResource, nullptr)) { + backendResource->drawCleanEpoch = bufferEpoch; + } else { + backendResource = + BufferImpl::EnsureBufferResourceForHandle(nullptr, entry.Res); + } + } + if (backendResource && backendResource->id != 0) { + // WHOLE-VS-RANGE IS THE RECORD's TEST, not `range.end == 0`: a + // base binding travels as kMGPipeWholeBuffer precisely so the + // extent is re-resolved HERE, against the storage this server + // holds, rather than frozen at the client's emission. + if (entry.Offset == 0 && entry.Size == MG_Pipe::kMGPipeWholeBuffer) { + BufferImpl::BindBufferBaseCached(GL_UNIFORM_BUFFER, lastUBOBinding, + backendResource->id); + } else { + const SizeT storage = backendResource->storageSize; + const SizeT rangeStart = + std::min(static_cast(entry.Offset), storage); + const SizeT rangeEnd = std::min( + static_cast(entry.Offset + entry.Size), storage); + BufferImpl::BindBufferRangeCached( + GL_UNIFORM_BUFFER, lastUBOBinding, backendResource->id, + static_cast(rangeStart), + static_cast(rangeEnd - rangeStart)); + } + } else { + MGLOG_E_ONCE("No backend buffer found for UBO binding, cannot bind UBO."); + } + continue; + } +#endif + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::Uniform, binding); auto& bufferObj = point.GetBoundObject(); auto range = point.GetRange(); @@ -3478,7 +7393,18 @@ namespace MobileGL::MG_Backend::DirectGLES { // and every previously touched unit's sampler-shadow row is exactly // what this pass last left there, re-running it is a provable no-op. auto& samplerPassMemo = backendProgram.GetSamplerPassMemo(); - const Uint32 programBackendStateVersion = currentProgram->GetBackendStateVersion(); + // P5e (pg), CONTRACT-P5E §5.5: THE SAMPLER PASS MEMO KEYS ON BindingsSerial + // on the handle arm. m_backendStateVersion is a frontend counter read on + // every draw (kimi row 105); what it is standing in for here is "has a + // sampler uniform's unit moved", and that is exactly what an applied + // set_program_bindings means. Truncated to the memo's Uint32 field, which is + // safe for a memo key - it is a change detector, not an ordering - and the + // record's serial would need four billion binding records to wrap. + const Uint32 programBackendStateVersion = +#if MOBILEGL_PIPE_PUSH + handleArm ? static_cast(programRecord->BindingsSerial) : +#endif + currentProgram->GetBackendStateVersion(); Bool samplerPassClean = samplerPassMemo.valid && samplerPassMemo.contextId == keys.contextId && samplerPassMemo.unitBindingsEpoch == keys.unitBindingsEpoch && @@ -3501,8 +7427,18 @@ namespace MobileGL::MG_Backend::DirectGLES { samplerPassMemo.count = 0; Bool memoisable = true; for (auto& samplerBinding : backendProgram.GetSamplerUniformBindings()) { + // P5e (pg): the unit glUniform1i put on this sampler - the second + // of the three post-link mutable fields, carried sparse and ascending + // by location in set_program_bindings' second tail. const auto unit = - currentProgram->GetUniformSamplerOrImageUnitIndex(samplerBinding.frontendLocation); +#if MOBILEGL_PIPE_PUSH + handleArm + ? PrgramImpl::ProgramSamplerUnitFromRecord( + *programRecord, samplerBinding.frontendLocation) + : +#endif + currentProgram->GetUniformSamplerOrImageUnitIndex( + samplerBinding.frontendLocation); if (unit == -1) continue; // Record the touched unit for the memo; a pass touching more // units than the memo can carry (or an out-of-range unit) @@ -3518,7 +7454,85 @@ namespace MobileGL::MG_Backend::DirectGLES { samplerBinding.lastAssignedUnit = unit; } - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.3. THE SAMPLER PASS, FROM RECORDS. + // + // Four frontend reads used to live in this loop body and every one of + // them has a carrier: the unit's sampler object is + // st.BoundSamplerStates[u] (null = the texture's built-in sampler, which + // is TextureResources[view.Texture].Params.BuiltinSampler); its fifteen + // values - lodBias among them - are the CSO record's + // (MGPipeValueTypes.h); and the sampled texture's FORMAT, which decides + // the raw-depth-fetch substitution, is + // SamplerViewCsos[View].View.InternalFormat. + // + // THE SUBSTITUTION ITSELF STAYS ON THE SERVER (ARCHITECTURE.md §5.5 + // assigns it there by name); what moves is where its two inputs come + // from. + if (TextureImpl::UnitTexturesByHandle()) { + const auto& st = MG_Pipe::MGPipeApplier(); + const auto index = static_cast(unit); + const Bool inViewWindow = index >= st.SamplerViewStart && + index - st.SamplerViewStart < st.SamplerViewCount && + index < st.BoundSamplerViews.size(); + const MG_Pipe::MGPBoundView view = + inViewWindow ? st.BoundSamplerViews[index] : MG_Pipe::MGPBoundView{}; + const Bool inStateWindow = index >= st.SamplerStateStart && + index - st.SamplerStateStart < st.SamplerStateCount && + index < st.BoundSamplerStates.size(); + const MG_Pipe::MGPipeHandle unitSampler = + inStateWindow ? st.BoundSamplerStates[index] : MG_Pipe::kMGPipeNullHandle; + + // The EFFECTIVE sampler of the unit: the bound CSO if there is one, + // else the sampled texture's built-in sampler CSO - exactly the + // override order GL states and the pre-handle body spells with + // `samplerObject ? ... : texture->GetSamplerObject()`. + MG_Pipe::MGPipeHandle effectiveCso = unitSampler; + if (MG_Pipe::MGPipeHandleIsNull(effectiveCso) && + !MG_Pipe::MGPipeHandleIsNull(view.Texture)) { + if (const auto* texRecord = PipeTextureRecordForHandle(view.Texture)) { + effectiveCso = texRecord->Params.BuiltinSampler; + } + } + const MG_Pipe::MGPipeSamplerCsoRecord* effectiveCsoRecord = + PipeSamplerCsoRecordForHandle(effectiveCso); + + if (samplerBinding.lodBiasLocation >= 0) { + const Float lodBias = + effectiveCsoRecord != nullptr ? effectiveCsoRecord->Params.lodBias : 0.0f; + if (lodBias != samplerBinding.lastAssignedLodBias) { + g_GLESFuncs.glUniform1f(samplerBinding.lodBiasLocation, lodBias); + samplerBinding.lastAssignedLodBias = lodBias; + } + } + + const auto* viewRecord = PipeSamplerViewRecordForHandle(view.View); + const Bool sampledIsTexture2D = + viewRecord != nullptr && + static_cast(viewRecord->View.Target) == TextureTarget::Texture2D; + if (samplerBinding.uniformType == GL_SAMPLER_2D && sampledIsTexture2D && + effectiveCsoRecord != nullptr && + NeedsRawDepthFetchSampler( + effectiveCsoRecord->Params, + static_cast(viewRecord->View.InternalFormat))) { + GetRawDepthFetchSampler()->Bind(unit); + MGLOG_D("Using raw depth fetch sampler on unit %d.", unit); + } else if (!MG_Pipe::MGPipeHandleIsNull(unitSampler)) { + // THE MINT IS GONE (§4.2): the unit's sampler is the CSO's own + // twin, the same one BindCurrentUnitSamplers binds, so the two + // arms cannot hand a unit back and forth between two driver + // samplers - and there is no arm left that creates a twin from a + // frontend SamplerObject. + if (auto* csoTwin = SamplerImpl::ResolveSamplerCsoTwin(unitSampler)) { + csoTwin->Bind(unit); + } + } else { + SamplerImpl::UnbindSampler(unit); + } + continue; + } +#endif + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); auto& samplerObject = textureUnit.GetSamplerObject(); const auto& texture2D = textureUnit.GetBindingSlot(TextureTarget::Texture2D).GetBoundObject(); @@ -3555,6 +7569,30 @@ namespace MobileGL::MG_Backend::DirectGLES { GetRawDepthFetchSampler()->Bind(unit); MGLOG_D("Using raw depth fetch sampler on unit %d.", unit); } else if (samplerObject) { +#if MOBILEGL_PIPE_PUSH + // P4a fable seam F-4: on the handle arm the unit's sampler is THE + // CSO's OWN TWIN, the same one BindCurrentUnitSamplers' record arm + // binds, so this pass and that walk cannot hand the unit back and + // forth between two driver samplers. The handle is read only inside + // the received window - outside it BoundSamplerStates holds whatever + // an earlier, wider set left - and a unit the window does not + // describe, or a handle whose record is gone, takes the pre-handle + // path below, which carries the handle to SyncToBackend and speaks + // there. + SamplerImpl::BackendSamplerObject* csoTwin = nullptr; + if (SamplerSubsystemEnabled()) { + const auto& st = MG_Pipe::MGPipeApplier(); + const auto index = static_cast(unit); + if (index >= st.SamplerStateStart && + index - st.SamplerStateStart < st.SamplerStateCount && + index < st.BoundSamplerStates.size()) { + csoTwin = SamplerImpl::ResolveSamplerCsoTwin(st.BoundSamplerStates[index]); + } + } + if (csoTwin != nullptr) { + csoTwin->Bind(unit); + } else { +#endif auto* backendSampler = ResolveUnitSamplerBackend(unit, samplerObject); if (!backendSampler) { auto& backendObj = @@ -3564,11 +7602,22 @@ namespace MobileGL::MG_Backend::DirectGLES { } backendSampler = backendObj.get(); } +#if MOBILEGL_PIPE_PUSH + backendSampler->SyncToBackend( + samplerObject, + static_cast(unit) < MG_Pipe::MGPipeApplier().BoundSamplerStates.size() + ? MG_Pipe::MGPipeApplier().BoundSamplerStates[static_cast(unit)] + : MG_Pipe::kMGPipeNullHandle); +#else backendSampler->SyncToBackend(samplerObject); +#endif // Syncing the object's parameters is not the same as putting it on the // unit: without this the driver kept sampling with the texture's own // parameters and every sampler object was inert. backendSampler->Bind(unit); +#if MOBILEGL_PIPE_PUSH + } +#endif } else { SamplerImpl::UnbindSampler(unit); } @@ -3600,13 +7649,38 @@ namespace MobileGL::MG_Backend::DirectGLES { // PrepareForCompute, where the current program (and therefore its registry twin) // is pinned for the duration. Prefers the per-draw stash those preparations wrote. static PrgramImpl::BackendProgramObjectImpl* GetCurrentBackendProgram() { - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); +#if MOBILEGL_PIPE_PUSH + // P5e (pg), kimi row 107: THE PER-SUB-DRAW ROW. This is reached once per indirect + // sub-draw for the gl_DrawID / gl_BaseVertex / gl_BaseInstance uniforms, and before P5e + // every one of those pulled GetProgramForDraw() and, on a stash miss, probed the client + // allocator. On the handle arm the stash key is the applier's own DrawProgram and the + // miss path is the by-handle resolver, which is record-first and names no frontend + // identity - so the whole entry point leaves the frontend. + if (ProgramHandleArm()) { + const MG_Pipe::MGPipeHandle cso = MG_Pipe::MGPipeApplier().DrawProgram; + if (MG_Pipe::MGPipeHandleIsNull(cso)) return nullptr; + const auto* const record = PrgramImpl::FindShaderCsoRecord(cso); + if (record == nullptr || record->Desc.LinkStatus == 0 || record->Desc.SpirvStatus == 0) { + return nullptr; + } + if (PrgramImpl::g_currentDrawProgramHandle == cso) { + return PrgramImpl::g_currentDrawBackendProgram; + } + return PrgramImpl::ResolveProgramTwin(cso); + } +#endif + const auto& currentProgram = MGB_CTX->GetProgramForDraw(); if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) { return nullptr; } if (PrgramImpl::g_currentDrawFrontendProgram == currentProgram.get()) { return PrgramImpl::g_currentDrawBackendProgram; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): MONOLITH GLUE ONLY now - the fifth of this family's five + // named scope sites, unreachable under a transport by the arm above. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif if (auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(currentProgram.get())) { return backendProgramSlot->get(); } @@ -3650,10 +7724,25 @@ namespace MobileGL::MG_Backend::DirectGLES { // flattening a batch that turns out to need per-sub-draw values is unrecoverable - // so an unanswerable program counts as needing them. Bool CurrentProgramMayNeedPerSubDrawBuiltins(Bool batchCarriesBaseVertices) { - const auto& currentProgram = MG_State::pGLContext->GetProgramForDraw(); const auto program = GetCurrentBackendProgram(); - if (!currentProgram || program == nullptr || - program->GetSyncedLinkVersion() != currentProgram->GetLinkVersion()) { + if (program == nullptr) return true; +#if MOBILEGL_PIPE_PUSH + // P5e (pg), kimi row 108: the same "is this twin current" question, from the record's + // serial instead of the frontend's link version. The ANSWER TO AN UNANSWERABLE PROGRAM + // IS STILL `true` - a flattened batch that turns out to need per-sub-draw values is + // unrecoverable, so "not known yet" has to count as "may need them", and a missing + // record is the most not-known-yet a program can be. + if (ProgramHandleArm()) { + const auto* const record = + PrgramImpl::FindShaderCsoRecord(MG_Pipe::MGPipeApplier().DrawProgram); + if (record == nullptr || program->GetSyncedShaderCsoSerial() != record->Serial) { + return true; + } + return program->ReadsDrawID() || (batchCarriesBaseVertices && program->ReadsBaseVertex()); + } +#endif + const auto& currentProgram = MGB_CTX->GetProgramForDraw(); + if (!currentProgram || program->GetSyncedLinkVersion() != currentProgram->GetLinkVersion()) { return true; } return program->ReadsDrawID() || (batchCarriesBaseVertices && program->ReadsBaseVertex()); @@ -3759,12 +7848,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // times; rasterizer discard means there are no fragments to gate at all, so replaying // would be pure cost with nothing to show for it. Both fall back to a single pass with an // open gate, i.e. to the pre-emulation behaviour, rather than to wrong data. - if (MG_State::pGLContext->IsTransformFeedbackActive() || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { + if (MGB_CTX->IsTransformFeedbackActive() || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { return 1; } - const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + const auto& parameters = MGB_CTX->GetRenderStateParameters(); Int surfaceWidth = 0; Int surfaceHeight = 0; if (!QueryCurrentSurfaceSize(surfaceWidth, surfaceHeight)) { @@ -3874,14 +7963,36 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!restart.DrawIsValid()) return; type = restart.IndexType(); indexSize = MG_Util::GetGLTypeSize(type); - const Bool useNative = drawIndirectBuffer != nullptr && SupportsNativeIndirectDraws(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): under an active transport the command buffer is the + // handle the verb record carried (MGPipeApplier's verb stash) and the SSBO view's + // resource resolves from it by handle - the frontend binding slot and the client slot + // allocator are never read (T2). + const MG_Pipe::MGPipeHandle verbBufferHandle = + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? MG_Pipe::MGPipeApplier().VerbIndirectBuffer + : MG_Pipe::kMGPipeNullHandle; +#endif + const Bool useNative = +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? !MG_Pipe::MGPipeHandleIsNull(verbBufferHandle) && SupportsNativeIndirectDraws() + : +#endif + drawIndirectBuffer != nullptr && SupportsNativeIndirectDraws(); if (useNative) { // gl_BaseInstance must observe GPU-written command fields; expose the indirect // buffer to the program's mg_IndirectParams SSBO view and address it per draw. const auto backendProgram = GetCurrentBackendProgram(); const Int paramsBinding = backendProgram ? backendProgram->GetIndirectParamsBinding() : -1; if (paramsBinding >= 0) { - auto* resource = BufferImpl::EnsureBufferResource(drawIndirectBuffer); + auto* resource = +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? BufferImpl::EnsureBufferResourceForHandle(nullptr, verbBufferHandle) + : +#endif + BufferImpl::EnsureBufferResource(drawIndirectBuffer); if (resource && resource->id != 0) { BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, static_cast(paramsBinding), resource->id); @@ -3946,12 +8057,32 @@ namespace MobileGL::MG_Backend::DirectGLES { // draw is what leaves a stale value, and restoring afterwards would only protect the // NEXT draw while these commands ran with the stale one. SetCurrentBaseVertex(0); - const Bool useNative = drawIndirectBuffer != nullptr && SupportsNativeIndirectDraws(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): see ExecuteIndexedIndirectCommands - the verb's handle, + // never the frontend binding slot or the client allocator. + const MG_Pipe::MGPipeHandle verbBufferHandle = + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? MG_Pipe::MGPipeApplier().VerbIndirectBuffer + : MG_Pipe::kMGPipeNullHandle; +#endif + const Bool useNative = +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? !MG_Pipe::MGPipeHandleIsNull(verbBufferHandle) && SupportsNativeIndirectDraws() + : +#endif + drawIndirectBuffer != nullptr && SupportsNativeIndirectDraws(); if (useNative) { const auto backendProgram = GetCurrentBackendProgram(); const Int paramsBinding = backendProgram ? backendProgram->GetIndirectParamsBinding() : -1; if (paramsBinding >= 0) { - auto* resource = BufferImpl::EnsureBufferResource(drawIndirectBuffer); + auto* resource = +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? BufferImpl::EnsureBufferResourceForHandle(nullptr, verbBufferHandle) + : +#endif + BufferImpl::EnsureBufferResource(drawIndirectBuffer); if (resource && resource->id != 0) { BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, static_cast(paramsBinding), resource->id); @@ -4000,15 +8131,48 @@ namespace MobileGL::MG_Backend::DirectGLES { // PrepareForDraw (nothing below can move either). The DISPATCH accessor: with a // pipeline bound this is its compute stage program, which is a whole program on its // own - the graphics composite a draw builds carries no compute stage. - const auto& currentProgram = MG_State::pGLContext->GetProgramForDispatch(); + // + // P5e (pa), kimi row 92: THE DISPATCH TWIN OF THE SAME RETIREMENT, the same arm and the + // same conjunction - the three callees this value reaches are the ones PrepareForDraw + // reaches, minus the attribute-values sync (a dispatch has no vertex stage) and the + // frontend link/SPIR-V gate below, which already has a record arm of its own. These are + // the 7 strict-lane entries of GetProgramForDispatch@DispatchCompute. + const SharedPtr noFrontendProgram; + const auto& currentProgram = + DrawProgramFromRecords() ? noFrontendProgram : MGB_CTX->GetProgramForDispatch(); const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys(); BufferImpl::SyncComputeBuffers(includeDispatchIndirectBuffer); TextureImpl::SyncNeccessaryTextures(textureKeys); TextureImpl::SyncImageTextureBindings(); TextureImpl::MarkWritableImageBufferTexturesGpuWritten(); - PrgramImpl::SyncCurrentProgram(currentProgram); +#if MOBILEGL_PIPE_PUSH + // P5e (pg), kimi row 92: the DISPATCH half of the same retirement - + // MGPipeApplier().DispatchProgram, which set_dispatch_program carried. A compute-only + // pipeline has no draw program at all, which is why the two applier fields are separate. + const Bool programHandleArm = ProgramHandleArm(); + const MG_Pipe::MGPipeHandle dispatchCso = + programHandleArm ? MG_Pipe::MGPipeApplier().DispatchProgram : MG_Pipe::kMGPipeNullHandle; + if (programHandleArm) { + PrgramImpl::SyncCurrentProgramByHandle(dispatchCso, false); + } else +#endif + { + PrgramImpl::SyncCurrentProgram(currentProgram); + } +#if MOBILEGL_PIPE_PUSH + if (programHandleArm) { + // The same gate as the frontend one below, from the record: a dispatch of a program + // the frontend reports unlinked, or one whose SPIR-V never arrived, binds nothing. + const auto* const record = PrgramImpl::FindShaderCsoRecord(dispatchCso); + if (record == nullptr || record->Desc.LinkStatus == 0 || record->Desc.SpirvStatus == 0) { + g_GLESFuncs.glUseProgram(0); + PrgramImpl::g_lastUsedBackendProgramId = 0; + return; + } + } else +#endif if (!currentProgram || !currentProgram->GetLinkStatus() || !currentProgram->GetSpirvStatus()) { g_GLESFuncs.glUseProgram(0); PrgramImpl::g_lastUsedBackendProgramId = 0; @@ -4022,21 +8186,32 @@ namespace MobileGL::MG_Backend::DirectGLES { // Compute programs need the same per-program resource sync as draws: // uniform-block bindings and sampler units only exist through the API // because layout(binding) is stripped from the transpiled ESSL. - BindCurrentProgramWithResources(currentProgram, textureKeys); + BindCurrentProgramWithResources(currentProgram, textureKeys, +#if MOBILEGL_PIPE_PUSH + dispatchCso +#else + MG_Pipe::kMGPipeNullHandle +#endif + ); } GLuint GetBackendProgramId(GLuint program) { - if (!MG_State::pGLContext->ValidateProgramName(program)) { + if (!MGB_CTX->ValidateProgramName(program)) { MGLOG_E_ONCE("Invalid frontend program object: %u", program); return 0; } - auto& programObject = MG_State::pGLContext->GetProgramObject(program); + auto& programObject = MGB_CTX->GetProgramObject(program); if (!programObject) { MGLOG_E_ONCE("Program object %u is null.", program); return 0; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside the + // scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(programObject.get()); auto& backendObj = backendProgramSlot ? *backendProgramSlot : PrgramImpl::g_backendProgramObjects.GetOrCreate(programObject); @@ -4094,7 +8269,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // color must go through glClearBufferfv, which GLES does not clamp. GLbitfield remainingMask = mask; if ((mask & GL_COLOR_BUFFER_BIT) != 0) { - const FloatVec4& cc = MG_State::pGLContext->GetRenderStateParameters().ClearColor; + const FloatVec4& cc = MGB_CTX->GetRenderStateParameters().ClearColor; const Bool outOfRange = cc.x() < 0.f || cc.x() > 1.f || cc.y() < 0.f || cc.y() > 1.f || cc.z() < 0.f || cc.z() > 1.f || cc.w() < 0.f || cc.w() > 1.f; // A widened attachment's stored alpha has to end up 1.0, and glClear applies ONE @@ -4152,15 +8327,32 @@ namespace MobileGL::MG_Backend::DirectGLES { GLfloat rb[4] = {0}; g_GLESFuncs.glReadPixels(100, 100, 1, 1, GL_RGBA, GL_FLOAT, rb); const GLenum rbErr = g_GLESFuncs.glGetError(); - const auto& feFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); int feDb0 = -1, feDb1 = -1; Uint feIdx = 0, feVer = 0; - if (feFbo) { - feIdx = feFbo->GetExternalIndex(); - feVer = feFbo->GetObjectVersion(); - feDb0 = (int)feFbo->GetDrawBuffers()[0]; - feDb1 = (int)feFbo->GetDrawBuffers()[1]; +#if MOBILEGL_PIPE_PUSH && MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb): DEBUG-ONLY, and still a BARRIER_PULLED read. A diagnostic + // is not a reason to touch the binding slot on an apply thread, so + // under a transport the same four numbers come off the draw record - + // the external index has no record counterpart and stays 0, which reads + // as "the record did not say". + if (FramebufferRecordArmIsMandatory()) { + if (const auto* rec = BoundFramebufferRecord( + FramebufferTarget::Draw)) { + feVer = static_cast(rec->ContentHash); + feDb0 = (int)rec->DrawBuffers[0]; + feDb1 = (int)rec->DrawBuffers[1]; + } + } else +#endif + { + const auto& feFbo = + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + if (feFbo) { + feIdx = feFbo->GetExternalIndex(); + feVer = feFbo->GetObjectVersion(); + feDb0 = (int)feFbo->GetDrawBuffers()[0]; + feDb1 = (int)feFbo->GetDrawBuffers()[1]; + } } MGLOG_D("CLEARV fbo=%d clrErr=0x%x rbErr=0x%x cc.x=%g cleared=%d firstDb=0x%x prevReadBuf=0x%x " "feFbo=%u feVer=%u feDb=[%d,%d] stored=(%g,%g,%g,%g)", @@ -4306,13 +8498,45 @@ namespace MobileGL::MG_Backend::DirectGLES { g_restartIndices.capacity = capacity; if (data != nullptr && bytes != 0) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + // Rewritten index list staged on the draw path: in a split build these + // bytes are the index-mirror-versus-ship decision of section 8. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndexClient, + static_cast(bytes)); + } } return true; } + // MONOLITH GLUE, AND LOUD ABOUT IT AS OF P5e (vi). CONTRACT-P5E §5.1 makes the restart + // substitution read st.IndexBuffer.Res, and the constructor above already does: its + // transport arm resolves the element buffer and the bytes from the applier and its own + // staged shadow (P5c hd), and it sets serverElementBinding >= 0 BEFORE the null test, + // so the tail at BoundElementArrayBufferId() is unreachable with a live transport. + // These two are therefore only called from the monolith branch - and the refusal below + // is what keeps that a fact rather than a reading: a future caller that reaches them + // from the apply thread aborts by name instead of pulling GetBoundVertexArray behind + // the family's back. + void RefuseElementArrayBufferFromTheFrontend(const char* entry) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + if (!BufferImpl::VertexInputReadsRecords()) return; + MGLOG_F("MGPipe: Fatal{RoleViolation, \"MGPipeSlots\"} - %s read the frontend VAO's " + "element-array slot with a live transport. The index buffer of this family is " + "MGPipeApplier().IndexBuffer.Res (CONTRACT-P5E §5.1); the frontend slot is " + "monolith glue and answers for whatever the CLIENT has bound now, not for the " + "record this draw is being applied from", + entry); + std::abort(); +#else + (void)entry; +#endif + } + const SharedPtr& BoundElementArrayBuffer() { static const SharedPtr none; - const auto& vao = MG_State::pGLContext->GetBoundVertexArray(); + RefuseElementArrayBufferFromTheFrontend("BoundElementArrayBuffer"); + const auto& vao = MGB_CTX->GetBoundVertexArray(); if (!vao) return none; return vao->GetIndexBufferBindingSlot().GetBoundObject(); } @@ -4320,6 +8544,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // The GL name PrepareForDraw left on GL_ELEMENT_ARRAY_BUFFER, i.e. what the // substitution has to put back. Uint BoundElementArrayBufferId() { + RefuseElementArrayBufferFromTheFrontend("BoundElementArrayBufferId"); const auto& ibo = BoundElementArrayBuffer(); if (!ibo) return 0; const auto* resource = BufferImpl::EnsureBufferResource(ibo); @@ -4328,13 +8553,13 @@ namespace MobileGL::MG_Backend::DirectGLES { } // namespace RestartSubstitutionKind ResolveRestartSubstitution(GLenum indexType) { - if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex)) { + if (!MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex)) { return RestartSubstitutionKind::None; } const Uint32 fixedMax = MG_Util::FixedRestartIndexForGLType(indexType); if (fixedMax == 0) return RestartSubstitutionKind::None; - const Uint32 restartIndex = MG_State::pGLContext->GetPrimitiveRestartIndex(); + const Uint32 restartIndex = MGB_CTX->GetPrimitiveRestartIndex(); if (restartIndex == fixedMax) return RestartSubstitutionKind::None; // Strictly greater, never truncated. GL 4.6 core 10.3.6 compares the fetched index // zero-extended against the full 32-bit state, so an index this type cannot hold matches @@ -4374,45 +8599,122 @@ namespace MobileGL::MG_Backend::DirectGLES { } const SizeT sourceIndexSize = MG_Util::GetGLTypeSize(indexType); const Uint32 fixedMax = MG_Util::FixedRestartIndexForGLType(indexType); - const Uint32 applicationRestartIndex = MG_State::pGLContext->GetPrimitiveRestartIndex(); - const auto& indexBuffer = BoundElementArrayBuffer(); + const Uint32 applicationRestartIndex = MGB_CTX->GetPrimitiveRestartIndex(); const Uint8* source = nullptr; SizeT indexCount = 0; SizeT sourceByteOffset = 0; + Bool hasElementBuffer = false; +#if MOBILEGL_BUILD_DISAGGREGATED + // -1: monolith, or this arm never resolved a backend id, and the tail asks + // BoundElementArrayBufferId() exactly as it always did. + Int serverElementBinding = -1; +#endif - if (indexBuffer) { - // The WHOLE buffer is rewritten, not just this draw's range, so that every index - // keeps its position: an indirect draw's firstIndex lives in GPU memory and cannot be - // adjusted from here. It is an ELEMENT index, so it survives widening unchanged. - const SizeT sizeBytes = indexBuffer->GetSize(); - if (sizeBytes < sourceIndexSize) { - return; // Nothing to restart on; let the driver see the draw unchanged. - } - if (sizeBytes > kMaxRestartRewriteBytes) { - MGLOG_E_ONCE("Draw skipped: GL_PRIMITIVE_RESTART with restart index %u needs the %zu-byte element " - "array buffer rewritten every draw, which is past the %zu-byte ceiling. Use " - "GL_PRIMITIVE_RESTART_FIXED_INDEX, or set glPrimitiveRestartIndex to the all-ones " - "value of the index type.", - applicationRestartIndex, sizeBytes, kMaxRestartRewriteBytes); - m_valid = false; - return; - } - // The shadow is the source of truth for CPU reads, but a persistent map or a - // shader write may have moved past it since the last sync. - indexBuffer->SyncPersistentMappedRange(); - indexBuffer->SyncGpuWrites(); - source = indexBuffer->MappedData(); - if (source == nullptr) { - MGLOG_E_ONCE("Draw skipped: GL_PRIMITIVE_RESTART with restart index %u needs a CPU-readable copy of " - "the bound element array buffer and none is available.", - applicationRestartIndex); - m_valid = false; - return; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): with an active transport this constructor runs on the + // apply thread, where the frontend BufferObject's legacy accessors are Fatal by name + // (BufferObject.cpp's "buffer-legacy-arm" refusal). The element buffer therefore + // resolves from the applier's set_index_buffer state (T2) and the bytes read from the + // server's own staged shadow - "the backend's ScopedRestartIndexSubstitution reads the + // index bytes it needs on its side" (CONTRACT-P5B d1), the same arm + // MultiDrawElementsIndirectCount takes for its command buffer. A draw whose record + // carried client indices (no index buffer bound) takes the client-pointer arm below, + // `indices` already resolved through the server's segment resolver. Monolith takes + // the original body, verbatim. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto& applierState = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPipeHandle elements = applierState.IndexBuffer.Res; + serverElementBinding = 0; + if (!MG_Pipe::MGPipeHandleIsNull(elements)) { + auto* elementResource = BufferImpl::FindBufferResourceForHandle(elements); + // The WHOLE buffer is rewritten, not just this draw's range, so that every + // index keeps its position: an indirect draw's firstIndex lives in GPU memory + // and cannot be adjusted from here. It is an ELEMENT index, so it survives + // widening unchanged. + const SizeT sizeBytes = BufferImpl::ResourceWidthForHandle(elements); + if (sizeBytes < sourceIndexSize) { + return; // Nothing to restart on; let the driver see the draw unchanged. + } + if (sizeBytes > kMaxRestartRewriteBytes) { + MGLOG_E_ONCE("Draw skipped: GL_PRIMITIVE_RESTART with restart index %u needs the %zu-byte element " + "array buffer rewritten every draw, which is past the %zu-byte ceiling. Use " + "GL_PRIMITIVE_RESTART_FIXED_INDEX, or set glPrimitiveRestartIndex to the all-ones " + "value of the index type.", + applicationRestartIndex, sizeBytes, kMaxRestartRewriteBytes); + m_valid = false; + return; + } + // The staged shadow is the source of truth for CPU reads on this side: a + // persistent map's blocks and a shader write's writeback were consumed by the + // applier before this verb ran. + const Uint8* hostBytes = elementResource ? elementResource->hostBytes : nullptr; + if (hostBytes == nullptr) { + MGLOG_E_ONCE("Draw skipped: GL_PRIMITIVE_RESTART with restart index %u needs a CPU-readable copy of " + "the bound element array buffer and none is available.", + applicationRestartIndex); + m_valid = false; + return; + } + // The M-3 rule for a whole-store reader (Managers.cpp:3238): for a store whose + // content the application SUPPLIED a coverage gap is a missing record and is + // Fatal by name; for one it ORPHANED the gap is its own undefined content and + // reading the shadow's zero-fill is exactly what the monolith arm's + // MappedData() answers. + const MG_Pipe::MGPipeResourceRecord* elementRecord = nullptr; + if (elements.Slot < applierState.Resources.size()) { + const auto& candidate = applierState.Resources[elements.Slot]; + if (candidate.Live && candidate.Gen == elements.Gen) elementRecord = &candidate; + } + if (elementRecord != nullptr && elementRecord->Desc.HasDefinedContent != 0) { + BufferImpl::RequireStagedCoverage(*elementResource, hostBytes, 0, sizeBytes, + "primitive_restart_substitution"); + } + hasElementBuffer = true; + serverElementBinding = static_cast(elementResource->id); + source = hostBytes; + indexCount = sizeBytes / sourceIndexSize; + sourceByteOffset = reinterpret_cast(indices); + } + } else +#endif + { + const auto& indexBuffer = BoundElementArrayBuffer(); + if (indexBuffer) { + hasElementBuffer = true; + // The WHOLE buffer is rewritten, not just this draw's range, so that every index + // keeps its position: an indirect draw's firstIndex lives in GPU memory and cannot be + // adjusted from here. It is an ELEMENT index, so it survives widening unchanged. + const SizeT sizeBytes = indexBuffer->GetSize(); + if (sizeBytes < sourceIndexSize) { + return; // Nothing to restart on; let the driver see the draw unchanged. + } + if (sizeBytes > kMaxRestartRewriteBytes) { + MGLOG_E_ONCE("Draw skipped: GL_PRIMITIVE_RESTART with restart index %u needs the %zu-byte element " + "array buffer rewritten every draw, which is past the %zu-byte ceiling. Use " + "GL_PRIMITIVE_RESTART_FIXED_INDEX, or set glPrimitiveRestartIndex to the all-ones " + "value of the index type.", + applicationRestartIndex, sizeBytes, kMaxRestartRewriteBytes); + m_valid = false; + return; + } + // The shadow is the source of truth for CPU reads, but a persistent map or a + // shader write may have moved past it since the last sync. + indexBuffer->SyncPersistentMappedRange(); + indexBuffer->SyncGpuWrites(); + source = indexBuffer->MappedData(); + if (source == nullptr) { + MGLOG_E_ONCE("Draw skipped: GL_PRIMITIVE_RESTART with restart index %u needs a CPU-readable copy of " + "the bound element array buffer and none is available.", + applicationRestartIndex); + m_valid = false; + return; + } + indexCount = sizeBytes / sourceIndexSize; + sourceByteOffset = reinterpret_cast(indices); } - indexCount = sizeBytes / sourceIndexSize; - sourceByteOffset = reinterpret_cast(indices); - } else { + } + if (!hasElementBuffer) { // No element array buffer: `indices` is a client pointer, so only the draw's own // range is readable and an indirect draw has nothing to read at all. if (count <= 0 || indices == nullptr || sourceIndexSize == 0) { @@ -4466,14 +8768,22 @@ namespace MobileGL::MG_Backend::DirectGLES { m_valid = false; return; } - m_previousBinding = BoundElementArrayBufferId(); + m_previousBinding = 0; +#if MOBILEGL_BUILD_DISAGGREGATED + if (serverElementBinding >= 0) { + m_previousBinding = static_cast(serverElementBinding); + } else +#endif + { + m_previousBinding = BoundElementArrayBufferId(); + } BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, g_restartIndices.id); m_substituted = true; m_indexType = destinationType; // The rewritten copy starts at byte 0 of the scratch buffer and holds one // destination-width element per source element, so an EBO-sourced draw keeps its ELEMENT // offset (rescaled to the new width) and a client-memory draw reads from the front. - m_indices = indexBuffer + m_indices = hasElementBuffer ? reinterpret_cast((sourceByteOffset / sourceIndexSize) * destinationIndexSize) : nullptr; } @@ -4502,13 +8812,7 @@ namespace MobileGL::MG_Backend::DirectGLES { #endif DrawSyncFlags syncBit = DrawSyncBit::None; PrepareForDraw(syncBit); - const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray(); - if (currentVAO) { - auto* backendVAOSlot = VertexArrayImpl::g_backendVertexArrayObjects.Find(currentVAO.get()); - if (backendVAOSlot && *backendVAOSlot) { - (*backendVAOSlot)->SyncClientSideAttributesForDrawArrays(currentVAO, first, count); - } - } + VertexArrayImpl::SyncClientSideVertexArraysForDrawArrays(first, count); ForEachViewportRoutingPass([&] { g_GLESFuncs.glDrawArrays(mode, first, count); }); @@ -4541,15 +8845,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // ladder and the indirect executors do. Without it every sub-draw of a // glMultiDrawArrays read draw index 0. const Bool feedDrawID = CurrentProgramReadsDrawID(); - const auto& currentVAO = MG_State::pGLContext->GetBoundVertexArray(); for (GLsizei i = 0; i < drawcount; ++i) { // Client-side arrays are uploaded per sub-draw range, like the single DrawArrays path. - if (currentVAO) { - auto* backendVAOSlot = VertexArrayImpl::g_backendVertexArrayObjects.Find(currentVAO.get()); - if (backendVAOSlot && *backendVAOSlot) { - (*backendVAOSlot)->SyncClientSideAttributesForDrawArrays(currentVAO, first[i], count[i]); - } - } + VertexArrayImpl::SyncClientSideVertexArraysForDrawArrays(first[i], count[i]); if (feedDrawID) SetCurrentDrawID(static_cast(i)); ForEachViewportRoutingPass([&] { g_GLESFuncs.glDrawArrays(mode, first[i], count[i]); @@ -4612,7 +8910,15 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): with an active transport the frontend binding slot + // is never read - the Execute* helpers resolve the buffer from the verb record's + // handle (T2). Monolith reads the slot as it always did. + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? SharedPtr(nullptr) + : +#endif + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteIndexedIndirectCommands(mode, type, indexSize, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, drawcount, stride, "MultiDrawElementsIndirect"); } @@ -4643,8 +8949,55 @@ namespace MobileGL::MG_Backend::DirectGLES { return; } - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): with an active transport both buffers resolve from the + // verb record's handles (MGPDrawIndirect::Buffer / ParameterBuffer) and the count + // reads from the SERVER's staged shadow - the frontend binding slots, the frontend + // accessors (B3) and the client allocator (T2) are never read. Monolith takes the + // original body below, verbatim. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto& applierState = MG_Pipe::MGPipeApplier(); + const SizeT commandOffset = reinterpret_cast(indirect); + const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(maxdrawcount - 1) + + sizeof(DrawElementsIndirectCommand); + auto* drawResource = BufferImpl::FindBufferResourceForHandle(applierState.VerbIndirectBuffer); + auto* paramResource = + BufferImpl::FindBufferResourceForHandle(applierState.VerbIndirectParameterBuffer); + const Uint8* const drawBytes = drawResource ? drawResource->hostBytes : nullptr; + const Uint8* const parameterBytes = paramResource ? paramResource->hostBytes : nullptr; + if (commandBytes > BufferImpl::ResourceWidthForHandle(applierState.VerbIndirectBuffer)) { + MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); + return; + } + if (drawcount < 0 || static_cast(drawcount) + sizeof(Uint32) > + BufferImpl::ResourceWidthForHandle(applierState.VerbIndirectParameterBuffer)) { + MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range"); + return; + } + // No staged shadow means no count to read, not a wrong one - the monolith body's + // own rule for a buffer with no CPU shadow. + if (parameterBytes == nullptr || drawBytes == nullptr) { + MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: CPU fallback cannot read the parameter or " + "draw-indirect buffer"); + return; + } + BufferImpl::RequireStagedCoverage(*drawResource, drawBytes, commandOffset, commandBytes, + "multidraw_elements_indirect_count_commands"); + BufferImpl::RequireStagedCoverage(*paramResource, parameterBytes, static_cast(drawcount), + static_cast(drawcount) + sizeof(Uint32), + "multidraw_elements_indirect_count_parameter"); + Uint32 actualDrawCount = 0; + std::memcpy(&actualDrawCount, parameterBytes + drawcount, sizeof(actualDrawCount)); + actualDrawCount = std::min(actualDrawCount, static_cast(maxdrawcount)); + ExecuteIndexedIndirectCommands(mode, type, indexSize, drawBytes + commandOffset, commandOffset, + nullptr, static_cast(actualDrawCount), stride, + "MultiDrawElementsIndirectCount"); + return; + } +#endif + + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!drawBuffer) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: no GL_DRAW_INDIRECT_BUFFER is bound"); return; @@ -4714,7 +9067,15 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): with an active transport the frontend binding slot + // is never read - the Execute* helpers resolve the buffer from the verb record's + // handle (T2). Monolith reads the slot as it always did. + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? SharedPtr(nullptr) + : +#endif + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteArraysIndirectCommands(mode, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, drawcount, stride, "MultiDrawArraysIndirect"); } @@ -4745,8 +9106,50 @@ namespace MobileGL::MG_Backend::DirectGLES { DrawSyncFlags syncBit = DrawSyncBit::IndirectBuffer | DrawSyncBit::Instancing; PrepareForDraw(syncBit); - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): see the indexed twin - the verb record's handles and + // the SERVER's staged shadow, never the frontend bindings or the client allocator. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto& applierState = MG_Pipe::MGPipeApplier(); + const SizeT commandOffset = reinterpret_cast(indirect); + const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(maxdrawcount - 1) + + sizeof(DrawArraysIndirectCommand); + auto* drawResource = BufferImpl::FindBufferResourceForHandle(applierState.VerbIndirectBuffer); + auto* paramResource = + BufferImpl::FindBufferResourceForHandle(applierState.VerbIndirectParameterBuffer); + const Uint8* const drawBytes = drawResource ? drawResource->hostBytes : nullptr; + const Uint8* const parameterBytes = paramResource ? paramResource->hostBytes : nullptr; + if (commandBytes > BufferImpl::ResourceWidthForHandle(applierState.VerbIndirectBuffer)) { + MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); + return; + } + if (drawcount < 0 || static_cast(drawcount) + sizeof(Uint32) > + BufferImpl::ResourceWidthForHandle(applierState.VerbIndirectParameterBuffer)) { + MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range"); + return; + } + if (parameterBytes == nullptr || drawBytes == nullptr) { + MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: CPU fallback cannot read the parameter or " + "draw-indirect buffer"); + return; + } + BufferImpl::RequireStagedCoverage(*drawResource, drawBytes, commandOffset, commandBytes, + "multidraw_arrays_indirect_count_commands"); + BufferImpl::RequireStagedCoverage(*paramResource, parameterBytes, static_cast(drawcount), + static_cast(drawcount) + sizeof(Uint32), + "multidraw_arrays_indirect_count_parameter"); + Uint32 actualDrawCount = 0; + std::memcpy(&actualDrawCount, parameterBytes + drawcount, sizeof(actualDrawCount)); + actualDrawCount = std::min(actualDrawCount, static_cast(maxdrawcount)); + ExecuteArraysIndirectCommands(mode, drawBytes + commandOffset, commandOffset, nullptr, + static_cast(actualDrawCount), stride, + "MultiDrawArraysIndirectCount"); + return; + } +#endif + + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!drawBuffer) { MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: no GL_DRAW_INDIRECT_BUFFER is bound"); return; @@ -4818,14 +9221,27 @@ namespace MobileGL::MG_Backend::DirectGLES { // The emulated shift has to be in place before PrepareForDraw, because that is what syncs the // VAO; a zero here is what un-shifts the arrays for the next ordinary draw. + // + // P3a (D-H2): this is the LEGACY arm's carrier. On the handle arm the draw's raw base + // instance rides in MGPVertexBuffers::BaseInstance - a ContentHash input, so a base-instance + // change that moves no buffer is still emitted rather than suppressed - and the server + // decides whether to shift, because emulation ownership is the server's. The scopes below + // are therefore compiled only where the pre-handle arm is (a pull build always). inline Uint32 EmulatedFetchBaseInstance(GLuint baseinstance) { return UseNativeBaseInstance() ? 0u : static_cast(baseinstance); } +#if MOBILEGL_PIPE_LEGACY_MEMOS +#define MGL_SCOPED_FETCH_BASE_INSTANCE(name, value) \ + const VertexArrayImpl::ScopedFetchBaseInstance name(value) +#else +#define MGL_SCOPED_FETCH_BASE_INSTANCE(name, value) ((void)(value)) +#endif + void DrawElementsInstancedBaseVertexBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) { DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing; - const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance)); + MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance)); PrepareForDraw(syncBit); const ScopedRestartIndexSubstitution restart(type, count, indices); if (!restart.DrawIsValid()) return; @@ -4862,7 +9278,7 @@ namespace MobileGL::MG_Backend::DirectGLES { void DrawElementsInstancedBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLuint baseinstance) { DrawSyncFlags syncBit = DrawSyncBit::IndexBuffer | DrawSyncBit::Instancing; - const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance)); + MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance)); PrepareForDraw(syncBit); const ScopedRestartIndexSubstitution restart(type, count, indices); if (!restart.DrawIsValid()) return; @@ -4905,7 +9321,15 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): with an active transport the frontend binding slot + // is never read - the Execute* helpers resolve the buffer from the verb record's + // handle (T2). Monolith reads the slot as it always did. + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? SharedPtr(nullptr) + : +#endif + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteIndexedIndirectCommands(mode, type, indexSize, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, 1, sizeof(DrawElementsIndirectCommand), "DrawElementsIndirect"); @@ -4914,7 +9338,7 @@ namespace MobileGL::MG_Backend::DirectGLES { void DrawArraysInstancedBaseInstance(GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) { DrawSyncFlags syncBit = DrawSyncBit::Instancing; - const VertexArrayImpl::ScopedFetchBaseInstance fetchScope(EmulatedFetchBaseInstance(baseinstance)); + MGL_SCOPED_FETCH_BASE_INSTANCE(fetchScope, EmulatedFetchBaseInstance(baseinstance)); PrepareForDraw(syncBit); SetCurrentBaseInstance(baseinstance); ForEachViewportRoutingPass([&] { @@ -4946,7 +9370,15 @@ namespace MobileGL::MG_Backend::DirectGLES { } const auto& drawIndirectBuffer = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.5): with an active transport the frontend binding slot + // is never read - the Execute* helpers resolve the buffer from the verb record's + // handle (T2). Monolith reads the slot as it always did. + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? SharedPtr(nullptr) + : +#endif + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); ExecuteArraysIndirectCommands(mode, commandBytes, reinterpret_cast(indirect), drawIndirectBuffer, 1, sizeof(DrawArraysIndirectCommand), "DrawArraysIndirect"); } @@ -5122,6 +9554,9 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(previousRead)); g_GLESFuncs.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(previousDraw)); FramebufferImpl::InvalidateFramebufferBindingCache(); +#if MOBILEGL_PIPE_PUSH + FramebufferImpl::InvalidateFramebufferHandleArmMemos(); +#endif if (!resolved) { MGLOG_E_ONCE("BlitFramebuffer: multisample resolve fallback failed"); } @@ -5191,8 +9626,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // restore. Drop the flag so it is not misattributed to the emulation's own work. DrainBlitErrors(); - if (MG_State::pGLContext->IsTransformFeedbackActive() && - !MG_State::pGLContext->IsTransformFeedbackPaused() && g_GLESFuncs.glPauseTransformFeedback) { + if (MGB_CTX->IsTransformFeedbackActive() && + !MGB_CTX->IsTransformFeedbackPaused() && g_GLESFuncs.glPauseTransformFeedback) { g_GLESFuncs.glPauseTransformFeedback(); m_pausedTransformFeedback = true; DrainBlitErrors(); @@ -5637,6 +10072,9 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(previousDraw)); g_GLESFuncs.glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(previousRead)); FramebufferImpl::InvalidateFramebufferBindingCache(); +#if MOBILEGL_PIPE_PUSH + FramebufferImpl::InvalidateFramebufferHandleArmMemos(); +#endif if (!ok) { MGLOG_E_ONCE("BlitFramebuffer: could not stage the source for the multisample replicate"); return false; @@ -5736,6 +10174,9 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(previousRead)); g_GLESFuncs.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(previousDraw)); FramebufferImpl::InvalidateFramebufferBindingCache(); +#if MOBILEGL_PIPE_PUSH + FramebufferImpl::InvalidateFramebufferHandleArmMemos(); +#endif } if (readSamples <= 0 && drawSamples > 0) { // Single-sample source into a multisample destination: ES rejects the call @@ -5947,12 +10388,270 @@ namespace MobileGL::MG_Backend::DirectGLES { return handled & mask; } +#if MOBILEGL_PIPE_PUSH + // ---- P5e (fb, CONTRACT-P5E.md §5.4): the same aspect plan, from the two RECORDS --------- + // + // The blit was the last entry point in this family that still reached into two frontend + // FramebufferObjects on every call - GetDrawBuffers, GetReadBuffer, GetAttachment and then + // six texture properties per aspect - and it did it on BOTH arms, the named one reaching + // the objects through the twin table's state note. Every one of those questions is a field + // of MGPFramebufferState: + // + // GetDrawBuffers() -> DrawBuffers[8] (attachment index, -1 = None) + // GetReadBuffer() -> ReadSurface matched against Color[] (the same match + // SyncReadBufferToBackend makes; ReadSurface is resolved + // from THIS framebuffer's own read buffer under every + // Target, Named included) + // GetAttachment(point) -> PushedSurfaceForAttachment + // IsTexture / IsLayered -> Kind / Layered + // GetTextureLevel / Layer -> Level / Layer + // GetFormat -> InternalFormat (the attachment's, which is the texture's) + // GetSamples -> record.Samples + // + // SAMPLES COMES OFF THE RECORD RATHER THAN OFF EACH TEXTURE, and that is a deliberate + // narrowing: the frontend form asked each endpoint texture, the record states it per + // FRAMEBUFFER. A framebuffer whose attachments disagree about sample count is incomplete, + // so the two cannot differ where this substitution is allowed to fire; and the test the + // answer feeds ("either end is multisample -> leave it to the driver") is conservative in + // the direction that hands the call back. + static FramebufferAttachmentType PushedReadBufferPoint(const MG_Pipe::MGPFramebufferState& record) { + if (MG_Pipe::MGPipeHandleIsNull(record.ReadSurface.Res)) return FramebufferAttachmentType::None; + for (Uint i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) { + const auto& color = record.Color[i]; + if (color.Res == record.ReadSurface.Res && color.Kind == record.ReadSurface.Kind && + color.Layered == record.ReadSurface.Layered && color.Level == record.ReadSurface.Level && + color.Layer == record.ReadSurface.Layer && + color.UploadTarget == record.ReadSurface.UploadTarget) { + return static_cast( + static_cast(FramebufferAttachmentType::Color0) + static_cast(i)); + } + } + return FramebufferAttachmentType::None; + } + + static GLbitfield BlitLayeredDestinationAspects(const MG_Pipe::MGPFramebufferState& readRecord, + const MG_Pipe::MGPFramebufferState& drawRecord, GLint srcX0, + GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, + GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask) { + if (mask == 0) return 0; + if (!g_GLESFuncs.glCopyImageSubData) return 0; + if (!MG_Util::SelfTest::BlitIgnoresDestinationArrayLayer(g_GLESFuncs)) return 0; + // The default framebuffer has no layers to get wrong. + if (readRecord.IsDefault != 0 || drawRecord.IsDefault != 0) return 0; + + const Int width = srcX1 - srcX0; + const Int height = srcY1 - srcY0; + const Bool oneToOne = width > 0 && height > 0 && (dstX1 - dstX0) == width && (dstY1 - dstY0) == height; + // The scissor clips a blit and does not clip a copy, so an enabled scissor makes the two + // different operations no matter how the rectangles line up. + const Bool scissorEnabled = + (RenderStateImpl::g_syncedRenderStateParameters.ScissorTestEnabledMask & 1u) != 0; + + using MobileGL::FramebufferAttachmentType; + struct AspectPlan { + GLbitfield bit; + FramebufferAttachmentType source; + FramebufferAttachmentType destination; + }; + // The buffer is FOUND rather than assumed to be slot 0, for the frontend form's reason: + // a blit writes every ENABLED draw buffer, and glDrawBuffers(NONE, NONE, NONE, + // COLOR_ATTACHMENT0) leaves slot 0 empty while still naming exactly one destination. + // -1 is the record's spelling of GL_NONE and is not "no such attachment". + Int enabledDrawBuffers = 0; + FramebufferAttachmentType colorDestination = FramebufferAttachmentType::None; + for (Uint i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) { + const Int8 index = drawRecord.DrawBuffers[i]; + if (index < 0) continue; + ++enabledDrawBuffers; + if (enabledDrawBuffers == 1) { + colorDestination = static_cast( + static_cast(FramebufferAttachmentType::Color0) + static_cast(index)); + } + } + const AspectPlan plans[] = { + {GL_COLOR_BUFFER_BIT, PushedReadBufferPoint(readRecord), colorDestination}, + {GL_DEPTH_BUFFER_BIT, FramebufferAttachmentType::Depth, FramebufferAttachmentType::Depth}, + {GL_STENCIL_BUFFER_BIT, FramebufferAttachmentType::Stencil, FramebufferAttachmentType::Stencil}, + }; + + GLbitfield handled = 0; + for (const AspectPlan& plan : plans) { + if ((mask & plan.bit) == 0) continue; + if (plan.source == FramebufferAttachmentType::Unknown || + plan.destination == FramebufferAttachmentType::Unknown || + plan.source == FramebufferAttachmentType::None || + plan.destination == FramebufferAttachmentType::None) { + continue; + } + const MG_Pipe::MGPSurface* const sourceSurface = + FramebufferImpl::PushedSurfaceForAttachment(readRecord, plan.source); + const MG_Pipe::MGPSurface* const destinationSurface = + FramebufferImpl::PushedSurfaceForAttachment(drawRecord, plan.destination); + if (sourceSurface == nullptr || destinationSurface == nullptr) continue; + // Renderbuffers have no layers, so a destination that is one cannot be hitting this. + if (sourceSurface->Kind != MG_Pipe::kMGPipeSurfaceKindTexture || + destinationSurface->Kind != MG_Pipe::kMGPipeSurfaceKindTexture) { + continue; + } + if (MG_Pipe::MGPipeHandleIsNull(sourceSurface->Res) || + MG_Pipe::MGPipeHandleIsNull(destinationSurface->Res)) { + continue; + } + // Layer 0 is the case the driver gets right, and a LAYERED attachment + // (glFramebufferTexture with no layer) blits its layer 0 by spec - neither is this + // defect. + if (destinationSurface->Layer == 0) continue; + if (destinationSurface->Layered != 0 || sourceSurface->Layered != 0) continue; + + // glCopyImageSubData moves texel blocks: same format both ends, or it is a different + // operation. Multisample endpoints would additionally have to agree on sample count, + // which is a resolve the driver still owns. + if (sourceSurface->InternalFormat != destinationSurface->InternalFormat) continue; + if (readRecord.Samples > 0 || drawRecord.Samples > 0) continue; + // Copying an image region onto itself is undefined for glCopyImageSubData, and a blit + // whose source and destination overlap is undefined for GL too - so this is not a + // shape to substitute FOR, it is one to leave exactly as the application wrote it. + if (sourceSurface->Res == destinationSurface->Res && + sourceSurface->Level == destinationSurface->Level && + sourceSurface->Layer == destinationSurface->Layer) { + continue; + } + + // A combined depth-stencil texture is ONE image to glCopyImageSubData: it carries both + // aspects across whether or not the mask asked for both. Taking only GL_DEPTH_BUFFER_BIT + // on a DEPTH24_STENCIL8 destination would overwrite a stencil the application asked to + // keep, so the copy is only allowed when the mask covers everything the format holds. + const auto format = static_cast(destinationSurface->InternalFormat); + const Bool hasDepth = MG_Util::IsDepthFormatInternalFormat(format); + const Bool hasStencil = MG_Util::IsStencilFormatInternalFormat(format); + if (hasDepth && (mask & GL_DEPTH_BUFFER_BIT) == 0) continue; + if (hasStencil && (mask & GL_STENCIL_BUFFER_BIT) == 0) continue; + // ... and having carried both, it must be credited with both, or the caller hands the + // stencil half to the driver and it lands on layer 0 after all. + const GLbitfield aspectBits = + hasDepth || hasStencil + ? static_cast((hasDepth ? GL_DEPTH_BUFFER_BIT : 0) | + (hasStencil ? GL_STENCIL_BUFFER_BIT : 0)) + : static_cast(GL_COLOR_BUFFER_BIT); + if ((handled & aspectBits) == aspectBits) continue; + + if (!oneToOne || scissorEnabled || (plan.bit == GL_COLOR_BUFFER_BIT && enabledDrawBuffers != 1)) { + MGLOG_E_ONCE("BlitFramebuffer: this driver ignores a non-zero destination array layer and this " + "blit cannot be expressed as a copy (%s), so it will land on layer 0", + !oneToOne ? "it scales or flips" + : scissorEnabled ? "the scissor test is enabled" + : "the destination has more than one draw buffer"); + continue; + } + + auto& backendSource = + TextureImpl::SyncTextureToBackendByHandle(sourceSurface->Res, + /*imageBindableStorageRequired=*/false); + if (!backendSource) continue; + const GLuint sourceName = backendSource->GetBackendTextureId(); + // BY VALUE past this point, for the reason the copy-image endpoint builder gives: + // the second sync can grow the registry and relocate the first result. + auto& backendDestinationSlot = + TextureImpl::SyncTextureToBackendByHandle(destinationSurface->Res, + /*imageBindableStorageRequired=*/false); + if (!backendDestinationSlot) continue; + const GLuint destinationName = backendDestinationSlot->GetBackendTextureId(); + if (sourceName == 0 || destinationName == 0) continue; + const GLenum sourceTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum( + static_cast(sourceSurface->TextureTarget)); + const GLenum destinationTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum( + static_cast(destinationSurface->TextureTarget)); + + ClearGLErrors(); + g_GLESFuncs.glCopyImageSubData(sourceName, sourceTarget, static_cast(sourceSurface->Level), + srcX0, srcY0, static_cast(sourceSurface->Layer), + destinationName, destinationTarget, + static_cast(destinationSurface->Level), dstX0, dstY0, + static_cast(destinationSurface->Layer), width, height, 1); + if (const GLenum error = g_GLESFuncs.glGetError(); error != GL_NO_ERROR) { + // The driver blit still runs for this aspect - onto the wrong layer, but the + // substitute has to leave the call no worse off than it found it. + MGLOG_E_ONCE("BlitFramebuffer: the layered-destination copy substitute failed with %s; the " + "driver blit will run instead and land on layer 0", + MG_Util::ConvertGLEnumToString(error).c_str()); + continue; + } + handled |= aspectBits; + } + return handled & mask; + } + + // The two endpoint records of a blit, or nulls. `fbo` null-or-default means "the record of + // whatever is bound to that target", which is what the bound arm asks; a named blit passes + // the handles its record carried. + static const MG_Pipe::MGPFramebufferState* BlitEndpointRecord(MG_Pipe::MGPipeHandle fbo, + FramebufferTarget boundTarget) { + const auto& st = MG_Pipe::MGPipeApplier(); + if (MG_Pipe::MGPipeHandleIsNull(fbo)) { + return boundTarget == FramebufferTarget::Read ? st.ReadFramebuffer() : st.DrawFramebuffer(); + } + return st.FramebufferRecordFor(fbo); + } +#endif // MOBILEGL_PIPE_PUSH + void BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) { #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG && MOBILEGL_ENABLE_SCOPE_MARKER DebugImpl::OpenGLScopeMarker marker(__func__); #endif +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.3): THE NAMED ARM. A blit record whose ReadFbo/DrawFbo are + // non-null is a glBlitNamedFramebuffer: both framebuffers resolve from the record's + // handles through the FBO twin table (the client's ScopedBlitBindings staging and this + // function's binding-slot read at the bottom are gone from the split path), and the + // sink is told the pair was consumed - a backend that reaches here without consuming + // it has no named arm and the verb declines there. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + auto& applierState = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPipeHandle readFbo = applierState.VerbBlitReadFbo; + const MG_Pipe::MGPipeHandle drawFbo = applierState.VerbBlitDrawFbo; + if (!MG_Pipe::MGPipeHandleIsNull(readFbo) || !MG_Pipe::MGPipeHandleIsNull(drawFbo)) { + applierState.VerbBlitNamedConsumed = true; + TextureImpl::SyncNeccessaryTextures(); + RenderStateImpl::SyncRenderState(); + + SyncAndBindFramebufferByHandle(readFbo, FramebufferTarget::Read, /*forceSync=*/true); + SyncAndBindFramebufferByHandle(drawFbo, FramebufferTarget::Draw, /*forceSync=*/true); + + MGLOG_D("ES BlitNamedFramebuffer({%u,%u} -> {%u,%u}, %d, %d, %d, %d, %d, %d, %d, %d, 0x%x, %s)", + readFbo.Slot, readFbo.Gen, drawFbo.Slot, drawFbo.Gen, srcX0, srcY0, srcX1, + srcY1, dstX0, dstY0, dstX1, dstY1, mask, + MG_Util::ConvertGLEnumToString(filter).c_str()); + // P5e (fb, §5.4): only the probed defect makes this do anything, and it now + // asks the two RECORDS. The state-note detour this used to take - the twin + // table handing back the frontend object it was synced from, plus + // pDefaultFramebufferInfo->defaultFBO for a default endpoint - is gone with the + // note itself; a named framebuffer always has a record (a Named record precedes + // every DSA site, ID-19), so the "missing object skips the workaround" case is + // now a missing record and means the same thing. + const auto* readRecord = BlitEndpointRecord(readFbo, FramebufferTarget::Read); + const auto* drawRecord = BlitEndpointRecord(drawFbo, FramebufferTarget::Draw); + if (readRecord != nullptr && drawRecord != nullptr) { + mask &= ~BlitLayeredDestinationAspects(*readRecord, *drawRecord, srcX0, srcY0, srcX1, + srcY1, dstX0, dstY0, dstX1, dstY1, mask); + } + if (mask != 0) { + IssueBlitWithResolveFallback(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, + mask, filter); + } + // The driver's bindings are now the two NAMED framebuffers; put the bound ones + // back exactly as the monolith named entry point does. + ForceBindCurrentFBO(FramebufferTarget::Read); + ForceBindCurrentFBO(FramebufferTarget::Draw); + DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { + MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); + }); + return; + } + } +#endif + TextureImpl::SyncNeccessaryTextures(); DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); @@ -5975,9 +10674,27 @@ namespace MobileGL::MG_Backend::DirectGLES { dstX1, dstY1, mask, MG_Util::ConvertGLEnumToString(filter).c_str()); // A no-op on every driver that honours a non-zero destination array layer, which is all // of them but the probed one. Whatever it performs itself is taken out of the mask. +#if MOBILEGL_PIPE_PUSH && MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, §5.4): the bound arm's two binding-slot reads were the last GetBoundObject + // pair on this path; under a transport the two bound RECORDS answer instead + // (FramebufferRecordFor(st.BoundFramebuffer[t]), through BlitEndpointRecord's null + // case). SyncCurrentFBO ran a few lines up and refuses if either is missing, so a null + // here means the blit is against the default framebuffer's record, which the overload + // declines on IsDefault. + if (FramebufferRecordArmIsMandatory()) { + const auto* readRecord = + BlitEndpointRecord(MG_Pipe::kMGPipeNullHandle, FramebufferTarget::Read); + const auto* drawRecord = + BlitEndpointRecord(MG_Pipe::kMGPipeNullHandle, FramebufferTarget::Draw); + if (readRecord != nullptr && drawRecord != nullptr) { + mask &= ~BlitLayeredDestinationAspects(*readRecord, *drawRecord, srcX0, srcY0, srcX1, + srcY1, dstX0, dstY0, dstX1, dstY1, mask); + } + } else +#endif mask &= ~BlitLayeredDestinationAspects( - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(), - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(), srcX0, srcY0, + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(), + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(), srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask); if (mask != 0) { IssueBlitWithResolveFallback(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); @@ -6039,8 +10756,8 @@ namespace MobileGL::MG_Backend::DirectGLES { #ifdef TRACY_ENABLE ZoneScopedNC(__func__, TRACY_ZONECOLOR_BACKEND); #endif - auto unit = MG_State::pGLContext->GetActiveTextureUnit(); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto unit = MGB_CTX->GetActiveTextureUnit(); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); auto textureTarget = MG_Util::ConvertGLEnumToTextureTarget(target); if (!TextureImpl::IsSupportedTextureTarget(textureTarget)) { @@ -6049,6 +10766,29 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.4): under an active transport the destination is the handle + // the copy_framebuffer_to_texture record carried; the unit binding slot and the client + // allocator are never read (T2/T4). + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle dstHandle = MG_Pipe::MGPipeApplier().VerbCopyTexDst; + auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.GetOrCreateByHandle(dstHandle); + if (backendTextureSlot == nullptr) { + MGLOG_E_ONCE("%s: the verb's destination texture handle {%u, %u} is refused by " + "the twin slot table (live generation %u)", + __func__, dstHandle.Slot, dstHandle.Gen, + TextureImpl::g_backendTextureObjects.LiveGenAt(dstHandle.Slot)); + return false; + } + auto& backendObj = *backendTextureSlot; + if (!backendObj) { + backendObj = MakeShared(); + } + backendObj->Bind(TextureImpl::ConvertTextureTargetToBackendGLEnum(textureTarget), unit); + return true; + } +#endif + const auto& bindingSlot = textureUnit.GetBindingSlot(textureTarget); { const auto& textureObject = bindingSlot.GetBoundObject(); @@ -6117,7 +10857,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // The frontend's current PACK parameters, for readbacks the ES driver serves // directly with the client's layout. static PixelStoreImpl::PackState PackStateFromContext() { - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); return {static_cast(packParams.Alignment), static_cast(packParams.RowLength), static_cast(packParams.SkipRows), static_cast(packParams.SkipPixels)}; } @@ -6237,6 +10977,15 @@ namespace MobileGL::MG_Backend::DirectGLES { if (existingLevelCount == 0) { return false; } +#if MOBILEGL_PIPE_PUSH + // P4a (D-M). glGenerateMipmap's storage grow reaches into the frontend texture's own + // level shadows to decide - and then to define - the levels the driver is about to + // fill. In monolith that is exactly what it does today and nothing here changes; under + // a split there is no client address space to reach into, so P8 gives this name teeth. + // Named and greppable rather than silent, so the site cannot quietly disappear before + // then. + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-storage"); +#endif const IntVec3 baseTexelSize = texture.GetMipmapTexelSize(uploadTarget, 0); const SizeT baseByteSize = texture.GetMipmapByteSize(uploadTarget, 0); @@ -6264,7 +11013,72 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2): the inverse of MG_Pipe::MGPipeResourceTargetForTextureTarget, for the two + // descriptor reads this file makes (Managers.cpp has the same inverse beside the storage + // sync; it is file-local there, and a header for eleven cases would be the wrong trade). + static TextureTarget TextureTargetForPipeResourceTarget(Uint8 pipeResourceTarget) { + switch (static_cast(pipeResourceTarget)) { + case MG_Pipe::MGPipeResourceTarget::Tex1D: return TextureTarget::Texture1D; + case MG_Pipe::MGPipeResourceTarget::Tex2D: return TextureTarget::Texture2D; + case MG_Pipe::MGPipeResourceTarget::Tex3D: return TextureTarget::Texture3D; + case MG_Pipe::MGPipeResourceTarget::Tex1DArray: return TextureTarget::Texture1DArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DArray: return TextureTarget::Texture2DArray; + case MG_Pipe::MGPipeResourceTarget::TexCube: return TextureTarget::TextureCubeMap; + case MG_Pipe::MGPipeResourceTarget::TexCubeArray: return TextureTarget::TextureCubeMapArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DMS: return TextureTarget::Texture2DMultisample; + case MG_Pipe::MGPipeResourceTarget::Tex2DMSArray: return TextureTarget::Texture2DMultisampleArray; + case MG_Pipe::MGPipeResourceTarget::TexRect: return TextureTarget::TextureRectangle; + case MG_Pipe::MGPipeResourceTarget::TexBuffer: return TextureTarget::TextureBuffer; + default: return TextureTarget::Unknown; + } + } + + // P5c (hd), P5e (tx2): the verification half, with the RECORD passed in instead of re-read + // from the verb stash - the caller has it and the two must not disagree about which texture + // is being verified. + static void EnsureGenerateMipmapStorageDescribed(const MG_Pipe::MGPipeResourceRecord& record) { + const auto& desc = record.Desc; + if (desc.Width == 0 || desc.Levels == 0) { + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-storage"); + } + Uint maxDimension = desc.Width; + const auto target = static_cast(desc.Target); + if (target != MG_Pipe::MGPipeResourceTarget::Tex1D && + target != MG_Pipe::MGPipeResourceTarget::Tex1DArray) { + maxDimension = std::max(maxDimension, desc.Height); + } + if (target == MG_Pipe::MGPipeResourceTarget::Tex3D) { + maxDimension = std::max(maxDimension, desc.Depth); + } + Uint requiredLevels = 1; + while (maxDimension > 1) { + maxDimension /= 2; + ++requiredLevels; + } + if (desc.Levels < requiredLevels) { + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-storage"); + } + } +#endif + static Bool EnsureGenerateMipmapStorageAllocated(const SharedPtr& texture) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // The frontend has already defined the generated chain before emitting this verb. + // Its resource_respecify carries that shape, so the server only verifies the + // descriptor; it must not allocate or dirty the client's level shadows again. + // P5c (hd): identity is the handle the generate_mipmap record carried + // (MGPMipPlan::Res, the verb stash) - the client allocator is never probed (T2). + const auto handle = MG_Pipe::MGPipeApplier().VerbMipRes; + const auto* record = PipeTextureRecordForHandle(handle); + if (record == nullptr) { + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-storage"); + } + EnsureGenerateMipmapStorageDescribed(*record); + return false; // No server-side shadow allocation was necessary. + } +#endif auto* mipmapTexture = dynamic_cast(texture.get()); MOBILEGL_ASSERT(mipmapTexture != nullptr, "GenerateMipmap requires mipmap texture storage."); Bool allocatedStorage = false; @@ -6307,7 +11121,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::ConvertGLEnumToString(err).c_str(), MG_Util::ConvertGLEnumToString(target).c_str(), MG_Util::ConvertTextureInternalFormatToString(format).c_str()); - MG_State::pGLContext->RecordError( + MGB_CTX->RecordError( ConvertGLESErrorToErrorCode(err), MakeUnique("DirectGLES", operation, MG_Util::ConvertGLEnumToString(err))); @@ -6331,18 +11145,261 @@ namespace MobileGL::MG_Backend::DirectGLES { class ScopedDetachedTextureFramebufferAttachments { public: +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2). THE SAME SWEEP, KEYED BY THE TEXTURE'S HANDLE. + // + // The frontend constructor below answers "which framebuffers attach this texture" by + // walking every live FBO twin's frontend FramebufferObject and comparing attachment + // SharedPtrs - kimi audit row 63, an unmemoised walk of client objects. The applier + // already holds the answer: every framebuffer's record carries its eleven MGPSurfaces + // and each one names its texture BY HANDLE (`MGPSurface::Res`), so the comparison is + // {slot, gen} against {slot, gen} and the level / layered / upload-target details the + // detach needs come off the same surface. + // + // This is the same reverse question fb's texture -> FBO-slot index answers for its + // attachment sync; here it is asked over the records directly, because the walk is on a + // rare path (per glGenerateMipmap) and does not need an index to be cheap. + explicit ScopedDetachedTextureFramebufferAttachments(MG_Pipe::MGPipeHandle textureHandle) { + if (MG_Pipe::MGPipeHandleIsNull(textureHandle)) return; + auto* twin = TextureImpl::ResolveTextureTwin(textureHandle); + if (twin == nullptr) return; + const GLuint backendTextureId = twin->GetBackendTextureId(); + const auto& st = MG_Pipe::MGPipeApplier(); + + FramebufferImpl::g_backendFramebufferObjects.ForEachLive( + [&](MG_Pipe::MGPipeHandle fbo, + const SharedPtr& backendFBO) { + if (!backendFBO) return; + const auto* record = st.FramebufferRecordFor(fbo); + if (record == nullptr || record->IsDefault != 0) return; + + const auto detachPoint = [&](const MG_Pipe::MGPSurface& surface, + FramebufferAttachmentType frontendType) { + if (surface.Kind != MG_Pipe::kMGPipeSurfaceKindTexture) return; + if (surface.Res.Slot != textureHandle.Slot || surface.Res.Gen != textureHandle.Gen) { + return; + } + GLenum backendAttachment = GL_NONE; + if (frontendType >= FramebufferAttachmentType::Color0 && + frontendType <= FramebufferAttachmentType::Color31) { + backendAttachment = backendFBO->GetBackendAttachmentType(frontendType); + } else { + backendAttachment = MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(frontendType); + } + if (backendAttachment == GL_NONE || backendAttachment == GL_UNKNOWN_MGL) return; + + GLenum textureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum( + static_cast(surface.UploadTarget)); + if (textureTarget == GL_UNKNOWN_MGL) { + textureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum( + static_cast(surface.TextureTarget)); + } + const GLuint backendFBOId = backendFBO->GetBackendFramebufferId(); + FramebufferImpl::BindFramebufferId(GL_DRAW_FRAMEBUFFER, backendFBOId); + if (surface.Layered != 0) { + g_GLESFuncs.glFramebufferTexture(GL_DRAW_FRAMEBUFFER, backendAttachment, 0, 0); + } else { + g_GLESFuncs.glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, backendAttachment, + textureTarget, 0, 0); + } + ClearGLErrors(); + m_detachedAttachments.push_back({backendFBOId, backendAttachment, textureTarget, + backendTextureId, static_cast(surface.Level), + surface.Layered != 0}); + }; + + for (SizeT i = 0; i < 8; ++i) { + detachPoint(record->Color[i], + static_cast( + static_cast(FramebufferAttachmentType::Color0) + i)); + } + detachPoint(record->Depth, FramebufferAttachmentType::Depth); + detachPoint(record->Stencil, FramebufferAttachmentType::Stencil); + }); + } +#endif + explicit ScopedDetachedTextureFramebufferAttachments( const SharedPtr& texture) { if (texture == nullptr) { return; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside + // the scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(texture.get()); if (!backendTextureSlot || !*backendTextureSlot) { return; } const GLuint backendTextureId = (*backendTextureSlot)->GetBackendTextureId(); + // The one direct-iteration site over a twin table. The legacy walk reads the map + // KEY, i.e. the raw frontend address, and has to test the entry's weak_ptr by hand + // before it dares dereference it; the body is otherwise identical, which is why it + // is lifted into a lambda both arms call. +#if MOBILEGL_PIPE_PUSH + // P5e (id, CONTRACT-P5E §4.1): the push arm walks the twin table through + // ForEachLive, which now hands over the entry's HANDLE and its twin - never a + // frontend SharedPtr, because a walk that produced one on every step was the last + // place the server held a frontend object across records. This site asks for the + // object BY HANDLE instead, through StateForHandle, inside the frontend-keyed + // registry scope opened above: one named, greppable read that the fb package + // replaces with its reverse index (texture -> FBO slots), rather than a property of + // the iteration that nothing could name. + // + // The set of framebuffers walked is UNCHANGED: an entry whose state object the old + // walk could not lock is one StateForHandle answers null for, and detachFrom + // returns immediately on a null FBO. + const auto detachFrom = [&](MG_State::GLState::FramebufferObject* stateFBO, + const SharedPtr& backendFBO) { + if (stateFBO == nullptr || !backendFBO || stateFBO->IsDefaultFramebuffer()) { + return; + } + + const auto& attachments = stateFBO->GetAllAttachmentObjects(); + for (SizeT i = 0; i < attachments.size(); ++i) { + const auto& attachmentObject = attachments[i]; + if (!attachmentObject.IsTexture() || attachmentObject.GetTexture().get() != texture.get()) { + continue; + } + + const auto frontendType = static_cast(i); + GLenum backendAttachment = GL_NONE; + if (frontendType >= FramebufferAttachmentType::Color0 && + frontendType <= FramebufferAttachmentType::Color31) { + backendAttachment = backendFBO->GetBackendAttachmentType(frontendType); + } else { + backendAttachment = MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(frontendType); + } + if (backendAttachment == GL_NONE || backendAttachment == GL_UNKNOWN_MGL) { + continue; + } + + GLenum textureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum( + attachmentObject.GetTextureUploadTarget()); + if (textureTarget == GL_UNKNOWN_MGL) { + textureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(texture->GetTarget()); + } + + const GLuint backendFBOId = backendFBO->GetBackendFramebufferId(); + FramebufferImpl::BindFramebufferId(GL_DRAW_FRAMEBUFFER, backendFBOId); + if (attachmentObject.IsLayered()) { + g_GLESFuncs.glFramebufferTexture(GL_DRAW_FRAMEBUFFER, backendAttachment, 0, 0); + } else { + g_GLESFuncs.glFramebufferTexture2D( + GL_DRAW_FRAMEBUFFER, backendAttachment, textureTarget, 0, 0); + } + ClearGLErrors(); + m_detachedAttachments.push_back( + {backendFBOId, backendAttachment, textureTarget, backendTextureId, + static_cast(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()}); + } + }; + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, CONTRACT-P5E.md §5.4): THE REVERSE INDEX REPLACES THE WALK. id's note + // above names this package as the one that does it, and the reason is the line it + // points at: StateForHandle handed the server a frontend FramebufferObject per live + // twin, which is a SharedPtr to client memory held across records - the last one in + // this family. The record already says which points hold which texture, so the + // question "which framebuffers have this one attached" is answered by an index + // maintained where the record is applied, and the points are re-read from the + // record rather than from the frontend attachment array. + // + // THE SITE ITSELF STAYS INSIDE THE FRONTEND-KEYED SCOPE (§4.4 keeps the class + // exactly here, at DirectGLES.cpp:8837 in the contract's list): every caller of + // this scope reaches it from a BARRIERED row - GenerateMipmap and the two CopyTex + // endpoints - so HandleOf on the texture the caller was handed is legal, and it is + // P8/P9 that retires it. What P5e removes is the per-FRAMEBUFFER frontend hold, + // which is not the caller's own object and has no such excuse. + if (FramebufferRecordArmIsMandatory()) { + const MG_Pipe::MGPipeHandle textureHandle = + TextureImpl::g_backendTextureObjects.HandleOf(texture.get()); + if (MG_Pipe::MGPipeHandleIsNull(textureHandle)) return; + const auto detachPoint = [&](FramebufferImpl::BackendFramebufferObject& backendFBO, + FramebufferAttachmentType frontendType, + const MG_Pipe::MGPSurface& surface) { + if (surface.Kind != MG_Pipe::kMGPipeSurfaceKindTexture) return; + if (!(surface.Res == textureHandle)) return; + + GLenum backendAttachment = GL_NONE; + if (frontendType >= FramebufferAttachmentType::Color0 && + frontendType <= FramebufferAttachmentType::Color31) { + backendAttachment = backendFBO.GetBackendAttachmentType(frontendType); + } else { + backendAttachment = MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(frontendType); + } + if (backendAttachment == GL_NONE || backendAttachment == GL_UNKNOWN_MGL) return; + + GLenum textureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum( + static_cast(surface.UploadTarget)); + if (textureTarget == GL_UNKNOWN_MGL) { + textureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum( + static_cast(surface.TextureTarget)); + } + + const GLuint backendFBOId = backendFBO.GetBackendFramebufferId(); + FramebufferImpl::BindFramebufferId(GL_DRAW_FRAMEBUFFER, backendFBOId); + if (surface.Layered != 0) { + g_GLESFuncs.glFramebufferTexture(GL_DRAW_FRAMEBUFFER, backendAttachment, 0, 0); + } else { + g_GLESFuncs.glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, backendAttachment, + textureTarget, 0, 0); + } + ClearGLErrors(); + m_detachedAttachments.push_back({backendFBOId, backendAttachment, textureTarget, + backendTextureId, static_cast(surface.Level), + surface.Layered != 0}); + }; + for (const MG_Pipe::MGPipeHandle fbo : + FramebufferImpl::FramebuffersAttachingTexture(textureHandle)) { + auto* twinSlot = FramebufferImpl::g_backendFramebufferObjects.FindByHandle(fbo); + if (twinSlot == nullptr || !*twinSlot) continue; // a recycled slot answers null + const auto* record = FramebufferImpl::PushedFramebufferRecord(fbo); + if (record == nullptr || record->IsDefault != 0) continue; + auto& backendFBO = **twinSlot; + for (Uint i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) { + detachPoint(backendFBO, + static_cast( + static_cast(FramebufferAttachmentType::Color0) + + static_cast(i)), + record->Color[i]); + } + detachPoint(backendFBO, FramebufferAttachmentType::Depth, record->Depth); + detachPoint(backendFBO, FramebufferAttachmentType::Stencil, record->Stencil); + } + return; + } +#endif + // Already inside #if MOBILEGL_PIPE_PUSH, so no second guard here: the arm choice + // below is the RUNTIME one. + if (EsprytSlotTablesEnabled()) { + FramebufferImpl::g_backendFramebufferObjects.ForEachLive( + [&](MG_Pipe::MGPipeHandle fbo, + const SharedPtr& backendFBO) { + const auto stateFBO = + FramebufferImpl::g_backendFramebufferObjects.StateForHandle(fbo); + detachFrom(stateFBO.get(), backendFBO); + }); + return; + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin(); + it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) { + // An entry whose state object died is only waiting for the next collection; + // the key is a dangling address, so it must not be dereferenced here. + if (it->second.stateRef.expired()) { + continue; + } + detachFrom(it->first, it->second.backend); + } +#endif +#else + // Pull build: exactly the pre-P2 walk, so this translation unit generates the + // same code it did before P2 (G1). for (auto it = FramebufferImpl::g_backendFramebufferObjects.begin(); it != FramebufferImpl::g_backendFramebufferObjects.end(); ++it) { auto* stateFBO = it->first; @@ -6393,6 +11450,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(attachmentObject.GetTextureLevel()), attachmentObject.IsLayered()}); } } +#endif } ~ScopedDetachedTextureFramebufferAttachments() { @@ -6557,6 +11615,46 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBindTexture(dstTarget, cachedBound ? cachedBound->GetBackendTextureId() : 0); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2): the two CPU-blit mip chains, driven by the RECORD the verb named. What changes + // against the P5c arms below is only the resolution: those took the frontend texture and + // re-derived its handle with a scoped `HandleOf` probe (the two sites §4.4 listed as + // barriered), and the handle is now simply passed in - the extent derivation, the per-level + // blits and the format question are §1's descriptor reads exactly as P5c wrote them. + static void GenerateDepthTexture2DMipmapByRecord(const MG_Pipe::MGPipeResourceRecord& record, + const SharedPtr& backendTexture) { + const auto& desc = record.Desc; + const GLuint textureId = backendTexture->GetBackendTextureId(); + for (Uint32 level = 1; level < desc.Levels; ++level) { + const IntVec3 srcSize = MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, level - 1); + const IntVec3 dstSize = MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, level); + BlitDepthTexture2D(textureId, static_cast(level - 1), 0, 0, + static_cast(srcSize.x()), static_cast(srcSize.y()), textureId, + static_cast(level), 0, 0, static_cast(dstSize.x()), + static_cast(dstSize.y())); + } + } + + static void GenerateColorTexture2DMipmapByRecord(const MG_Pipe::MGPipeResourceRecord& record, + const SharedPtr& backendTexture) { + const auto& desc = record.Desc; + const GLenum filter = + IsIntegerColorFormat(static_cast(desc.InternalFormat)) ? GL_NEAREST : GL_LINEAR; + const GLuint textureId = backendTexture->GetBackendTextureId(); + for (Uint32 level = 1; level < desc.Levels; ++level) { + const IntVec3 srcSize = MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, level - 1); + const IntVec3 dstSize = MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, level); + BlitColorTexture2D(textureId, static_cast(level - 1), 0, 0, static_cast(srcSize.x()), + static_cast(srcSize.y()), textureId, static_cast(level), 0, 0, + static_cast(dstSize.x()), static_cast(dstSize.y()), filter); + } + } +#endif + static void GenerateDepthTexture2DMipmap( const SharedPtr& texture, const SharedPtr& backendTexture) { @@ -6568,6 +11666,12 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* mipmapTexture = dynamic_cast(texture.get()); MOBILEGL_ASSERT(mipmapTexture != nullptr, "Depth mipmap generation requires mipmap storage."); + + // P5e (tx2): the transport arm that stood here - a scoped `HandleOf(texture.get())` + // probe to re-derive a handle the generate_mipmap record already carried (CONTRACT-P5E + // §4.4's site 9161) - is GenerateDepthTexture2DMipmapByRecord above, reached from + // GenerateMipmapByRecord with the handle passed in. This overload is the monolith body. + const Uint mipLevelCount = mipmapTexture->GetMipmapLevelCount(); MOBILEGL_ASSERT(mipLevelCount > 0, "Depth mipmap generation requires allocated storage."); @@ -6593,6 +11697,10 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* mipmapTexture = dynamic_cast(texture.get()); MOBILEGL_ASSERT(mipmapTexture != nullptr, "Color mipmap generation requires mipmap storage."); + + // P5e (tx2): the transport arm that stood here is GenerateColorTexture2DMipmapByRecord + // above (CONTRACT-P5E §4.4's site 9216); this overload is the monolith body. + const Uint mipLevelCount = mipmapTexture->GetMipmapLevelCount(); MOBILEGL_ASSERT(mipLevelCount > 0, "Color mipmap generation requires allocated storage."); @@ -6631,19 +11739,49 @@ namespace MobileGL::MG_Backend::DirectGLES { // Bind necessary FBO and texture BindCurrentFBO(FramebufferTarget::Read); - Uint activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit(); - const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject((Int)activeTextureUnit) - .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) - .GetBoundObject(); - auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); - if (!backendTextureSlot || !*backendTextureSlot) { - MGLOG_E_ONCE("CopyTexSubImage2D: No backend texture found for texture %u.", - textureObject ? textureObject->GetExternalIndex() : 0); - return; + Uint activeTextureUnit = MGB_CTX->GetActiveTextureUnit(); + TextureImpl::BackendTextureObject* dstBackendTexture = nullptr; + TextureInternalFormat mgInternalFormat{}; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.4): with an active transport the destination resolves from + // the handle the copy_framebuffer_to_texture record carried (MGPCopyFromFramebuffer:: + // Dst, the verb stash) and its format is the applier descriptor's - the client's + // texture-unit binding slot and the client slot allocator are never read (T2/T4). + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle dstHandle = MG_Pipe::MGPipeApplier().VerbCopyTexDst; + const auto* dstRecord = PipeTextureRecordForHandle(dstHandle); + // P5e (tx2): SYNCED, not merely looked up. The destination of a copy is not sampled + // by the draw program, so the per-draw unit list - which is the sampler-view window + // now - never brings it across; before P5e the frontend walk over every slot of every + // touched unit happened to sync it as a side effect. The named narrowing (P5e-5) says + // the union that still covers every texture includes "the waited texture ops", and + // this is one of them: it syncs its own endpoint by handle. + auto& backendTexture = TextureImpl::SyncTextureToBackendByHandle(dstHandle); + if (!backendTexture || dstRecord == nullptr) { + MGLOG_E_ONCE("CopyTexImage2D: the verb's destination texture {%u, %u} has no twin " + "or no applier record on this side", + dstHandle.Slot, dstHandle.Gen); + return; + } + dstBackendTexture = backendTexture.get(); + mgInternalFormat = static_cast(dstRecord->Desc.InternalFormat); + } else +#endif + { + const auto& textureObject = MGB_CTX->GetTextureUnitObject((Int)activeTextureUnit) + .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) + .GetBoundObject(); + auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); + if (!backendTextureSlot || !*backendTextureSlot) { + MGLOG_E_ONCE("CopyTexSubImage2D: No backend texture found for texture %u.", + textureObject ? textureObject->GetExternalIndex() : 0); + return; + } + dstBackendTexture = backendTextureSlot->get(); + mgInternalFormat = textureObject->GetFormat(); } - (*backendTextureSlot)->Bind(target, activeTextureUnit); + dstBackendTexture->Bind(target, activeTextureUnit); - auto mgInternalFormat = textureObject->GetFormat(); GLenum format = GL_DEPTH_COMPONENT; GLenum type = GL_UNSIGNED_INT; TextureImpl::GenerateTextureFormatInfo(mgInternalFormat, &internalformat, &format, &type, @@ -6677,7 +11815,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); }); - auto currentTex = (GLint)(*backendTextureSlot)->GetBackendTextureId(); + auto currentTex = (GLint)dstBackendTexture->GetBackendTextureId(); DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); }); @@ -6726,17 +11864,38 @@ namespace MobileGL::MG_Backend::DirectGLES { // Bind necessary FBO and texture BindCurrentFBO(FramebufferTarget::Read); - auto activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit(); - const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(activeTextureUnit) - .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) - .GetBoundObject(); - auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); - if (!backendTextureSlot || !*backendTextureSlot) { - MGLOG_E_ONCE("CopyTexSubImage2D: No backend texture found for texture %u.", - textureObject ? textureObject->GetExternalIndex() : 0); - return; + auto activeTextureUnit = MGB_CTX->GetActiveTextureUnit(); + TextureImpl::BackendTextureObject* dstBackendTexture = nullptr; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.4): see CopyTexImage2D - the record's Dst handle, never the + // client's unit binding slot or the client allocator (T2/T4). + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle dstHandle = MG_Pipe::MGPipeApplier().VerbCopyTexDst; + // P5e (tx2): synced by handle - see CopyTexImage2D's note. A copy destination is not + // in the sampler-view window, so nothing else on this path would build its twin. + auto& backendTexture = TextureImpl::SyncTextureToBackendByHandle(dstHandle); + if (!backendTexture) { + MGLOG_E_ONCE("CopyTexSubImage2D: the verb's destination texture {%u, %u} has no " + "twin on this side", + dstHandle.Slot, dstHandle.Gen); + return; + } + dstBackendTexture = backendTexture.get(); + } else +#endif + { + const auto& textureObject = MGB_CTX->GetTextureUnitObject(activeTextureUnit) + .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) + .GetBoundObject(); + auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); + if (!backendTextureSlot || !*backendTextureSlot) { + MGLOG_E_ONCE("CopyTexSubImage2D: No backend texture found for texture %u.", + textureObject ? textureObject->GetExternalIndex() : 0); + return; + } + dstBackendTexture = backendTextureSlot->get(); } - (*backendTextureSlot)->Bind(target, activeTextureUnit); + dstBackendTexture->Bind(target, activeTextureUnit); DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); @@ -6758,7 +11917,7 @@ namespace MobileGL::MG_Backend::DirectGLES { }); } else { MGLOG_D("%s: Backend depth", __func__); - auto currentTex = (*backendTextureSlot)->GetBackendTextureId(); + auto currentTex = dstBackendTexture->GetBackendTextureId(); DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__](auto err) { MGLOG_D("ES error (%s:%d): %s", file, line, MG_Util::ConvertGLEnumToString(err).c_str()); }); @@ -6799,6 +11958,14 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* mipmapTexture = MG_State::GLState::AsMipmapTexture(texture.get()); if (mipmapTexture == nullptr) return false; +#if MOBILEGL_PIPE_PUSH + // P4a (D-M). The three-channel float mipmap fallback filters the CHAIN ON THE CPU out + // of the frontend's level shadows and uploads the result. Monolith keeps doing exactly + // that; a split server has no shadow to filter, and P8 is what retires it. The texels + // themselves are the other half of ARCHITECTURE.md's generate_mipmap item, which is + // P8's too. + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-cpu-fallback"); +#endif const Uint levelCount = mipmapTexture->GetMipmapLevelCount(); constexpr Int kChannels = 3; @@ -6855,6 +12022,65 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.2: glGenerateMipmap's body once the texture came from + // VerbMipRes. Same four arms as the frontend body, with every shape question answered from + // the descriptor. + // + // RULING 14, AND WHY THE ROW IS NOT FLIPPED HERE. The unit read this arm retires is what the + // wait existed for, but ONE frontend contact is left on the path and it is not tx2's to + // retire: the RGB16F/RGB32F CPU filter reaches into the client's level shadows + // (GenerateThreeChannelFloatMipmapOnCpu, kimi audit row 61) and P8/P9 own the pull. So the + // brief's escape applies - the arm is not complete, the row stays kWaitApplied and the flip + // is listed as trailing - and the record stays barriered, which is what keeps the CPU arm's + // refusal a refusal rather than a wrong picture. + static void GenerateMipmapByRecord(GLenum target, MG_Pipe::MGPipeHandle mipRes, + const MG_Pipe::MGPipeResourceRecord& record, + const SharedPtr& backendTexture) { + const auto format = static_cast(record.Desc.InternalFormat); + const auto textureTarget = TextureTargetForPipeResourceTarget(record.Desc.Target); + + if (format == TextureInternalFormat::R11FG11FB10F || IsDepthOnlyFormat(format) || + format == TextureInternalFormat::RGB16F || format == TextureInternalFormat::RGB32F) { + // The storage verification EnsureGenerateMipmapStorageAllocated's disaggregated arm + // already makes, with the record passed in rather than re-read from VerbMipRes: the + // frontend defined the generated chain before emitting this verb, so the server only + // checks that the descriptor it was given can carry it. + EnsureGenerateMipmapStorageDescribed(record); + } + if (format == TextureInternalFormat::RGB16F || format == TextureInternalFormat::RGB32F) { + // The CPU filter reads and writes the CLIENT's level shadows. It already aborts + // three frames deep at the MipmapStorage guard under a transport; naming it here + // says which emulation, which is what the guard cannot. + MG_Pipe::MGPipeUnmigratedEmulation("generate-mipmap-cpu-filter"); + } + if (IsDepthOnlyFormat(format)) { + GenerateDepthTexture2DMipmapByRecord(record, backendTexture); + return; + } + if (format == TextureInternalFormat::R11FG11FB10F && textureTarget == TextureTarget::Texture2D) { + GenerateColorTexture2DMipmapByRecord(record, backendTexture); + return; + } + + const GLenum backendTarget = + TextureImpl::ConvertTextureTargetToBackendGLEnum(MG_Util::ConvertGLEnumToTextureTarget(target)); + // TempTextureUnit, not the active unit: the scratch bind is the backend's own and + // BindCurrentTextures re-establishes the sampling bindings regardless. The ACTIVE unit + // was never anything but the frontend's way of naming the texture, and the record names + // it now. + backendTexture->Bind(backendTarget, TextureImpl::TempTextureUnit); + // ANGLE/Mesa may validate the currently bound FBO while generating mipmaps, so the + // source texture is detached from every framebuffer that attaches it - by handle, over + // the applier's own framebuffer records. + ScopedDetachedTextureFramebufferAttachments detachedAttachments(mipRes); + ScopedCompleteFramebufferBinding completeFramebuffer; + ClearGLErrors(); + g_GLESFuncs.glGenerateMipmap(backendTarget); + RecordGLError("glGenerateMipmap", backendTarget, format); + } +#endif + void PatchParameteri(GLenum pname, GLint value) { if (g_GLESFuncs.glPatchParameteri == nullptr) return; g_GLESFuncs.glPatchParameteri(pname, value); @@ -6864,8 +12090,34 @@ namespace MobileGL::MG_Backend::DirectGLES { #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG && MOBILEGL_ENABLE_SCOPE_MARKER DebugImpl::OpenGLScopeMarker marker(__func__); #endif - auto unitIndex = MG_State::pGLContext->GetActiveTextureUnit(); - auto& unit = MG_State::pGLContext->GetTextureUnitObject(unitIndex); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.2 / ruling 14 (ID-92's neighbour): GENERATE_MIPMAP RESOLVES + // ITS TEXTURE FROM VerbMipRes, NEVER FROM THE ACTIVE UNIT. + // + // The verb's own record names the texture (MGPMipPlan::Res, stashed as VerbMipRes) and + // EnsureGenerateMipmapStorageAllocated's disaggregated arm has read it since P5c. What + // stood here instead was GetActiveTextureUnit + GetTextureUnitObject + the target's + // binding slot - three BARRIER_PULLED reads to re-derive a handle the record carried - + // and they are the ONLY reason this row still waits: ClientSession.cpp parks the client + // so that the unit it reads is the unit the call was made on. With the unit read gone the + // wait has nothing left to protect, which is what lets PipeCalls.def's WaitClass column + // for GenerateMipmap move to kWaitNone. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto mipRes = MG_Pipe::MGPipeApplier().VerbMipRes; + auto& backendTexture = TextureImpl::SyncTextureToBackendByHandle(mipRes); + const auto* record = PipeTextureRecordForHandle(mipRes); + if (!backendTexture || record == nullptr) { + MGLOG_E_ONCE("MGPipe: generate_mipmap names texture {%u, %u}, which has no applier " + "record or no driver texture; the call is dropped", + mipRes.Slot, mipRes.Gen); + return; + } + GenerateMipmapByRecord(target, mipRes, *record, backendTexture); + return; + } +#endif + auto unitIndex = MGB_CTX->GetActiveTextureUnit(); + auto& unit = MGB_CTX->GetTextureUnitObject(unitIndex); auto& slot = unit.GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)); auto& texture = slot.GetBoundObject(); MOBILEGL_ASSERT(texture != nullptr, "GenerateMipmap requires a bound texture."); @@ -6993,6 +12245,21 @@ namespace MobileGL::MG_Backend::DirectGLES { static SharedPtr SyncRenderbufferObjectToBackend( const SharedPtr& renderbufferObject) { if (!renderbufferObject) return nullptr; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, CONTRACT-P5E.md §4.4): the scope at this site is DELETED, and with it the + // arm that needed it. The only caller is the glCopyImageSubData endpoint builder, and a + // RENDERBUFFER endpoint is refused on the wire before it is emitted and again at the + // sink (PipeApplier.cpp's ServerUnmigratedVerbFatal("CopyImageSubData+RENDERBUFFER")), + // so under a transport nothing can reach here with one. Saying so by name is better + // than a find-or-MINT against the frontend address that the refusal already proved + // unreachable - if it ever becomes reachable, the name is where to start. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + MGLOG_F("MGPipe: Fatal{UnmigratedVerb, \"CopyImageSubData+RENDERBUFFER\"} a renderbuffer " + "endpoint reached the backend under an active transport, where both the client " + "emitter and the sink refuse one"); + std::abort(); + } +#endif SharedPtr backendRenderbufferObject; if (auto* slot = RenderbufferImpl::g_backendRenderbufferObjects.Find(renderbufferObject.get())) { backendRenderbufferObject = *slot; @@ -7086,6 +12353,35 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* srcMipmap = MG_State::GLState::AsMipmapTexture(srcEndpoint.Texture.get()); auto* dstMipmap = MG_State::GLState::AsMipmapTexture(dstEndpoint.Texture.get()); if (!srcMipmap || !dstMipmap) return; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5b package i1's ONE backend edit, and the contract names this site + // (MG_Remote/CONTRACT-P5B.md §2 i1 "the copy-image-shadow-mirror emulation", ruling + // §6.7). Migrating glCopyImageSubData moves the first blocker off the client's + // Fatal{UnmigratedVerb} and onto the Fatal below, on every Espryt copy between two + // textures with CPU shadows - so the ruling is: UNDER A REAL TRANSPORT THE SERVER SKIPS + // THE MIRROR, and the client-side mirror ROADMAP P8 names ("CopyImage 镜像搬到 client") + // stays P8's. + // + // WHAT THE SKIP LOSES IS BOUNDED BY TWO FATALS, which is the whole reason it is allowed + // to be a skip rather than a port: a later glGetTexImage of the destination served from + // the shadow is class C wave 3 (Fatal{UnmigratedVerb, "GetTexImage"}, P9) and a texture + // re-mint that re-uploads the level is Fatal{UnmigratedEmulation, "texture-remint-pull"} + // (Managers.cpp:5634). Neither can silently read the un-mirrored shadow. + // + // BEHIND #if MOBILEGL_BUILD_DISAGGREGATED so the pull build's code does not move (G1), + // and the arm is the TRANSPORT and not the build - build-split runs its unit and + // integration-gpu lanes under MOBILEGL_TRANSPORT=monolith, where this mirror is on an + // ordinary correct path and must still run. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) return; +#endif +#if MOBILEGL_PIPE_PUSH + // P4a (D-M). glCopyImageSubData's CPU-shadow mirror copies the source level's shadow + // rows into the DESTINATION's shadow so a later readback of the destination sees what + // the GPU copy put there. Both shadows are the client's, so this is the clearest case + // in the family of an emulation that cannot survive a split; ROADMAP puts the move + // itself in P8 and this names the site until then. + MG_Pipe::MGPipeUnmigratedEmulation("copy-image-shadow-mirror"); +#endif const auto srcUploadTarget = srcEndpoint.Texture->GetUploadTargets()[0]; const auto dstUploadTarget = dstEndpoint.Texture->GetUploadTargets()[0]; @@ -7243,147 +12539,42 @@ namespace MobileGL::MG_Backend::DirectGLES { void BindImageTexture(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format) { - (void)texture; (void)level; (void)layered; (void)layer; (void)access; (void)format; - TextureImpl::SyncImageTextureBinding(unit); - } - - void GetIntegeri_v(GLenum target, GLuint index, GLint* data) { - if (!data) return; - - switch (target) { - case GL_SHADER_STORAGE_BUFFER_BINDING: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - *data = obj ? static_cast(obj->GetExternalIndex()) : 0; - return; - } - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } - case GL_IMAGE_BINDING_NAME: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Texture ? static_cast(imageBinding.Texture->GetExternalIndex()) : 0; - return; - } - case GL_IMAGE_BINDING_LEVEL: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Level; - return; - } - case GL_IMAGE_BINDING_LAYERED: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Layered; - return; - } - case GL_IMAGE_BINDING_LAYER: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = imageBinding.Layer; - return; - } - case GL_IMAGE_BINDING_ACCESS: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = static_cast(imageBinding.Access); - return; - } - case GL_IMAGE_BINDING_FORMAT: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - *data = static_cast(imageBinding.Format); - return; - } - default: - if (g_GLESFuncs.glGetIntegeri_v) { - g_GLESFuncs.glGetIntegeri_v(target, index, data); - } else { - *data = 0; - } +#if MOBILEGL_PIPE_PUSH && MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb): see NoteImageUnitBoundWithoutReadingTheFrontend. Under a transport this + // entry point records the high-water mark and lets the next validate point's sweep do + // the bind from the record; reading the frontend image binding here is the + // GetImageTextureBinding row that rule F forbids, and `bind_shader_image` is not a + // barriered row. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + TextureImpl::NoteImageUnitBoundWithoutReadingTheFrontend(unit, texture != 0); return; } +#endif + (void)texture; + TextureImpl::SyncImageTextureBinding(unit); } - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) { + // Only the pnames MG_Impl/GLImpl/Getter/GL_Getter.cpp has no case for reach here. Every + // indexed pname naming FRONTEND state - the indexed buffer bindings, the per-unit + // texture/sampler bindings, the image-unit bindings, the viewport rectangles, the indexed + // capabilities - is answered there and returns before the table is consulted, so the arms + // this function used to carry for GL_SHADER_STORAGE_BUFFER_* and GL_IMAGE_BINDING_* were + // unreachable duplicates of the frontend's, and they did not even agree with it (the + // frontend reports the range glBindBufferRange was ASKED for, verbatim and unclamped; these + // clamped it to the buffer's current storage). In practice what arrives is + // GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE, which the driver owns. + void GetIntegeri_v(GLenum target, GLuint index, GLint* data) { if (!data) return; - - switch (target) { - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } - default: - if (g_GLESFuncs.glGetInteger64i_v) { - g_GLESFuncs.glGetInteger64i_v(target, index, data); - } else { - *data = 0; - } - return; - } - } - - void GetProgramiv(GLuint program, GLenum pname, GLint* params) { - if (!params) return; - GLuint backendProgramId = GetBackendProgramId(program); - if (!backendProgramId) { - params[0] = 0; - return; + if (g_GLESFuncs.glGetIntegeri_v) { + g_GLESFuncs.glGetIntegeri_v(target, index, data); + } else { + *data = 0; } - g_GLESFuncs.glGetProgramiv(backendProgramId, pname, params); } // NOTE the shape here, and do not "simplify" it back to GetBackendProgramId(): this entry @@ -7405,10 +12596,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // effect by the block's next use. void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) { if (!storageBlockName) return; - if (!MG_State::pGLContext->ValidateProgramName(program)) return; - auto& programObject = MG_State::pGLContext->GetProgramObject(program); + if (!MGB_CTX->ValidateProgramName(program)) return; + auto& programObject = MGB_CTX->GetProgramObject(program); if (!programObject) return; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside the + // scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif auto* backendProgramSlot = PrgramImpl::g_backendProgramObjects.Find(programObject.get()); if (!backendProgramSlot || !*backendProgramSlot) return; auto& backendObj = *backendProgramSlot; @@ -7602,7 +12798,7 @@ namespace MobileGL::MG_Backend::DirectGLES { template static Bool StoreReadbackRowsToClient(GLsizei width, GLsizei height, SizeT dstPixelBytes, void* pixels, const char* what, FillRow&& fillRow) { - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : width); const SizeT dstRowStride = AlignPixelRow(rowPixels * dstPixelBytes, packParams.Alignment); const SizeT dstOffset = static_cast(std::max(packParams.SkipRows, 0)) * dstRowStride + @@ -7610,7 +12806,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT rowBytes = static_cast(width) * dstPixelBytes; const SizeT packedSize = dstOffset + static_cast(height - 1) * dstRowStride + rowBytes; const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); const SizeT pboOffset = reinterpret_cast(pixels); if (pixelPackBufferObject && pboOffset + packedSize > pixelPackBufferObject->GetSize()) { MGLOG_E_ONCE("ReadPixels: %s readback PBO is too small", what); @@ -8592,7 +13788,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); if (!pixelPackBufferObject && pixels == nullptr) { return true; } @@ -8821,8 +14017,15 @@ namespace MobileGL::MG_Backend::DirectGLES { if (width <= 0 || sliceHeight <= 0 || sliceCount <= 0) { return true; } +#if MOBILEGL_PIPE_PUSH + // P4a (D-M). glGetTexImage is answered out of the frontend's own level shadow, + // converted to the requested format and type. Monolith is unchanged; a split server + // holds no shadow to convert, and the readback family as a whole is P3b/P4b's and + // P8's rather than this phase's. + MG_Pipe::MGPipeUnmigratedEmulation("get-tex-image-shadow"); +#endif const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); if (!pixelPackBufferObject && pixels == nullptr) { return true; } @@ -9089,7 +14292,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // and legacy GL_RED reads) goes through the wide-format conversion, which picks a wide type // the driver accepts for the current attachment. GL_PACK_SWAP_BYTES has no ES equivalent, so // it always takes the conversion path (which swaps on the CPU). - const Bool packSwapBytes = MG_State::pGLContext->GetPixelStoreParameters(false).SwapBytes; + const Bool packSwapBytes = MGB_CTX->GetPixelStoreParameters(false).SwapBytes; // The read buffer is what glReadPixels reads, so the frontend's READ binding is exactly // the right thing to ask here. const Bool forceOpaqueAlpha = FramebufferImpl::IsAlphaWidenedFallbackReadAttachment(); @@ -9132,7 +14335,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // (the driver-level binding used to stay on the user PBO after this call, // capturing subsequent client-memory readbacks into it). auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); Bool usePBO = false; GLuint packBufferId = 0; if (pixelPackBufferObject) { @@ -9242,16 +14445,21 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("GetTexImage: SyncCurrentFBO()"); FramebufferImpl::SyncCurrentFBO(); - auto activeTextureUnit = MG_State::pGLContext->GetActiveTextureUnit(); + auto activeTextureUnit = MGB_CTX->GetActiveTextureUnit(); MGLOG_D("GetTexImage: active texture unit = %u", activeTextureUnit); - const auto& textureObject = MG_State::pGLContext->GetTextureUnitObject(activeTextureUnit) + const auto& textureObject = MGB_CTX->GetTextureUnitObject(activeTextureUnit) .GetBindingSlot(MG_Util::ConvertGLEnumToTextureTarget(target)) .GetBoundObject(); MGLOG_D("GetTexImage: bound texture object = %p (name=%u)", textureObject.get(), textureObject ? textureObject->GetExternalIndex() : 0); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside the + // scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); if (!backendTextureSlot || !*backendTextureSlot) { @@ -9468,7 +14676,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Each slice is packed as its own 2D image, so the per-slice call must not apply // GL_PACK_SKIP_IMAGES / GL_PACK_IMAGE_HEIGHT itself - this walks the destination // over them, using the same layout StoreWideRowsToClient computes. - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT dstPixelBytes = GetReadbackDstPixelSize(conversionMapping, type); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : size.x()); @@ -9558,7 +14766,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Handle PBO. The pack binding is scoped: it returns to the resting 0 state // on every exit path, so a later readback can never land in a stale PBO. auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); Bool usePBO = false; GLuint packBufferId = 0; if (pixelPackBufferObject) { @@ -9693,6 +14901,9 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(previousDrawFramebuffer)); } FramebufferImpl::InvalidateFramebufferBindingCache(); +#if MOBILEGL_PIPE_PUSH + FramebufferImpl::InvalidateFramebufferHandleArmMemos(); +#endif // A driver that refuses the query leaves both at 0. Fall back to what the EGL config // was chosen with, which is what the surface actually has. @@ -9744,8 +14955,25 @@ namespace MobileGL::MG_Backend::DirectGLES { auto* depthTexture = defaultFBOInfo->depthAttachment.get(); auto* stencilTexture = defaultFBOInfo->stencilAttachment.get(); - if (depthTexture) depthTexture->SetInternalFormat(depthFormat); - if (stencilTexture) stencilTexture->SetInternalFormat(stencilFormat); +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged != nullptr) { + // P5c ev (CONTRACT-P5C §4.2): with an active transport the attachments are CLIENT + // memory and this thread may not write them - the backend fills MGPSurfaceInfo + // and posts, and the client applies it to its own pDefaultFramebufferInfo on the + // GL thread. FORMAT ONLY, exactly as the monolith arm below: Width/Height stay 0, + // which is the consumer's cue to leave the placeholder extent untouched. The + // callback's presence IS the transport probe - the server session installs it at + // Accept, and under monolith nobody ever does. + MG_Pipe::MGPSurfaceInfo info{}; + info.InternalFormat = static_cast(depthFormat); + info.IsDefault = 1; + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&info); + } else +#endif + { + if (depthTexture) depthTexture->SetInternalFormat(depthFormat); + if (stencilTexture) stencilTexture->SetInternalFormat(stencilFormat); + } MGLOG_D("DirectGLES: default framebuffer depth=%d stencil=%d float=%d; published attachment " "formats depth=%d stencil=%d", depthBits, stencilBits, floatDepth ? 1 : 0, static_cast(depthFormat), @@ -9950,6 +15178,22 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool InitDisplayAndContext(EGLint surfaceBit, NativeWindowType window = static_cast(0)) { DestroyEGLContext(); +#if MOBILEGL_PIPE_PUSH + // DIAGNOSE the twin-table arm here, at backend startup, so an operator who set + // MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS into a combination that leaves no + // arm at all is told so by name, in the log, before the first draw. + // + // Diagnose, and deliberately NOT resolve: resolving raises + // Fatal{PipeLegacyMemosDisabled}, and this function runs inside eglMakeCurrent, which + // the integration harness pre-flights in a FORKED CHILD + // (MG_IntegrationTest/Harness/HeadlessGL.cpp). A child that dies on a signal is reported + // to the parent as "no usable GPU/display/ICD" and every scenario in the lane is + // SKIPPED - so the stop became a green lane that ran nothing, on exactly the two env + // vars the D14/D18 A/B is driven with (ROADMAP.md:7). The stop now belongs to the first + // twin lookup, which happens in a scenario body where a crash IS a test failure. + DiagnoseEsprytSlotArm(); +#endif + g_Display = g_EGLFuncs.eglGetDisplay(EGL_DEFAULT_DISPLAY); if (g_Display == EGL_NO_DISPLAY) return false; @@ -10121,6 +15365,9 @@ namespace MobileGL::MG_Backend::DirectGLES { BufferImpl::InvalidateIndexedBufferBindingCache(); BufferImpl::InvalidatePixelBufferBindingCaches(); FramebufferImpl::InvalidateFramebufferBindingCache(); +#if MOBILEGL_PIPE_PUSH + FramebufferImpl::InvalidateFramebufferHandleArmMemos(); +#endif PixelStoreImpl::InvalidatePackStateCache(); // The render-state shadow belongs in this list for the same reason as the ones above: // it describes the real ES context, which outlives the MobileGL context that is @@ -10638,6 +15885,12 @@ namespace MobileGL::MG_Backend::DirectGLES { BufferImpl::UnpackRingOnPresent(); BufferImpl::UploadRingOnPresent(); BufferImpl::TrimBufferPool(); + + // THE frame boundary for the MGPipe counters: publish this frame's plots, fold the + // frame into the run totals and, every 120th frame, emit the summary line. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::OnPresent(); + } } void DestroyEGLContext() { @@ -10648,6 +15901,9 @@ namespace MobileGL::MG_Backend::DirectGLES { ScratchFBOImpl::OnBackendContextDestroyed(); ReleasePackedWordScratchTexture(); FramebufferImpl::InvalidateFramebufferBindingCache(); +#if MOBILEGL_PIPE_PUSH + FramebufferImpl::InvalidateFramebufferHandleArmMemos(); +#endif VertexArrayImpl::InvalidateVAOBindingCache(); PixelStoreImpl::InvalidatePackStateCache(); PrgramImpl::InvalidateBroadcastMemo(); diff --git a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h index 949479655..63a48bc4d 100644 --- a/MobileGL/MG_Backend/DirectGLES/DirectGLES.h +++ b/MobileGL/MG_Backend/DirectGLES/DirectGLES.h @@ -92,8 +92,6 @@ namespace MobileGL::MG_Backend::DirectGLES { void BindImageTexture(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format); void GetIntegeri_v(GLenum target, GLuint index, GLint* data); - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data); - void GetProgramiv(GLuint program, GLenum pname, GLint* params); void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding); Bool InitWindowSurface(NativeWindowType window); Bool InitPbufferSurface(EGLint width, EGLint height); diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.cpp b/MobileGL/MG_Backend/DirectGLES/Managers.cpp index e0012f3ce..63c4a9af9 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Managers.cpp @@ -7,14 +7,41 @@ // End of Source File Header #include "Managers.h" +#include +#if MOBILEGL_PIPE_PUSH +// P3a: the handle-shaped resource op table, the applier's records and the reverse channel. +// Both headers are compiled into the library only under push, so they are included here +// under the same condition - a pull build must gain no declaration it cannot link. +#include +#include +#include +// P5e (pg): the DEFINITION of the archive MGPipeShaderCsoRecord holds by SharedPtr. PipeApply.h +// needs only the name - the deleter is captured where the archive is constructed - but the +// program build reads through it, so the one translation unit that does needs the whole type. +#include +#endif +#if MOBILEGL_BUILD_DISAGGREGATED +// R-11's server-owned staging copy. Header-only and package v1's; see its own header block for +// why GLESBufferResource does not simply gain a member. +#include +#include +#include +// P5c (ct): object_death's producer (CONTRACT-P5C.md §5.2) - the death notice's split arm +// emits the record through the client's emit helper instead of hopping a stack struct to the +// apply thread. +#include +#endif + #include "Utils.h" #include "DirectGLES.h" #include "BackendObject_DirectGLES.h" #include +#include #include #include #include +#include #include #include #include @@ -168,6 +195,286 @@ namespace MobileGL::MG_Backend::DirectGLES { [] { std::atexit(+[] { g_processTeardown = true; }); }); } +#if MOBILEGL_PIPE_PUSH + namespace { + // P2 step e2's dispatcher: the frontend told us an object died, so free its slot and + // drop its twin NOW. One entry point for all six kinds, because the answer is the same + // for all six - which is why the notice carries the kind rather than there being six + // ops tables. + // + // Each arm below names the registry GLOBAL of its kind, but DestroyByLifetimeId is + // static: it is answered by every table of that kind that exists at the moment - the + // global's own and any by-value copy a fixture or a context reset is holding - and + // the slot goes back once, after all of them have let go (SlotTables.h, the holder + // list). Naming one instance here is a spelling, not a choice of holder. + // + // A notice that arrives after exit() has begun is dropped: past that point the twin's + // destructor must not call into the driver (see InProcessTeardown()), and the process + // is about to hand every GPU object back anyway. That twin is a deliberate leak, not + // garbage for a later collection - there is none on this arm. + void OnFrontendStateObjectDestroyed(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + if (InProcessTeardown()) return; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (ct), CONTRACT-P5C.md §5.2: with an active transport the death crosses AS A + // RECORD - object_death, the framebuffer family's first wire delete opcode. The GL + // thread resolves the dying object's handle in its OWN allocator inside + // EmitObjectDeathRecord: no handle means the server never saw the object and + // NOTHING crosses (which replaces the mailbox's unconditional delivery), and a + // handle means the record's EmitAndWait orders the death against in-flight verbs + // that name it - the one property the blocking RunOnApplyThread hop provided and + // the only one P5c keeps. The sink (ServerVerbSink::OnObjectDeath) releases the + // kind's twin table by handle on the apply thread, so neither a lifetime-id probe + // nor the client's allocator is touched from there any more. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Remote::Server::ServerLoop::OnApplyThread()) { + // P5e (id), CONTRACT-P5E §4.4: THE RECORD IS THE ONLY DELIVERY NOW. What used + // to sit here was a mailbox hop for the ONE shape the record cannot carry - a + // transport configured with no client session at all, i.e. a ServerLoop + // fixture driving a server role with no client, never a real split (there a + // session exists whenever the server does). It posted a stack struct to the + // apply thread, which re-entered this function and ran the switch below; that + // switch's DestroyByLifetimeId probes and FREES in the CLIENT'S allocator from + // the apply thread, which is precisely the read P5e is retiring - and it is + // not a read a barrier could ever make safe, because under run-ahead there is + // no wait behind which the allocator stands still. + // + // WHAT REPLACES IT: nothing, and that is the answer rather than an omission. A + // fixture with no client session has no client allocator worth the name and no + // handle ever crossed, so there is no twin the server built from a record; its + // twins die with the backend at Stop (InProcessTeardown / the registry's own + // destruction), which is what already happened for every death raised past + // `Running() == false`. ID-96 enumerates the fixtures this changed and what + // each one drives instead; every one of them now drives `object_death` by + // handle through the applier, which is the delivery a real split uses. + // ID-96, ANSWERED BY MEASUREMENT: the set of fixtures that lose death delivery + // here is EMPTY. A probe that aborted in exactly this branch (NoSession with a + // RUNNING server loop, i.e. the only shape the deleted hop ever fired for) was + // run over the whole unit lane (2204 entries) and the whole integration-split + // lane (111) and never fired once. The one fixture family that could reach it - + // ServerLoopTest.cpp's ServerFixture / EglServerFixture, the only server role in + // the tree with no client session - has exactly one case that lets a re-keyed + // frontend object die off the apply thread + // (ServerLoopEglTest.FrontendFramebufferDeathDeletesOnTheContextOwner), and that + // case is already refused at its FIRST step by P5c's allocator guard, so its + // `framebuffer.reset()` was unreachable at 2fde7034 too. + (void)MG_Remote::Client::EmitObjectDeathRecord(kind, lifetimeId); + return; + } + // Falling through HERE under a transport means the death was raised ON the apply + // thread - the server backend destroying a frontend object it created itself + // (Magma's hidden blit/depth-mipmap resources). Those sites carry their own named + // scope (MagmaP7AllocatorDebtScope), so the allocator probe below is admitted + // there and refused everywhere else; this function no longer opens a scope of its + // own, because the one arm that needed it is the mailbox hop that just went. +#endif + switch (kind) { + case MG_Pipe::MGPipeKind::Texture: + TextureImpl::g_backendTextureObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::Framebuffer: + FramebufferImpl::g_backendFramebufferObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::Renderbuffer: + RenderbufferImpl::g_backendRenderbufferObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::SamplerCso: + SamplerImpl::g_backendSamplerObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::ShaderCso: + PrgramImpl::g_backendProgramObjects.DestroyByLifetimeId(lifetimeId); + break; + case MG_Pipe::MGPipeKind::SamplerViewCso: + // P4a (D-I1, D5): the sixth kind, and the only one whose notice names a + // lifetime id that belongs to ANOTHER object - a sampler view is minted off its + // texture's id, one per ITextureObject, so ~TextureObjectBase raises this arm + // and the texture arm above from the same id. That is legal precisely because + // the two kinds have separate slot spaces, and it is why the two arms are + // written out separately rather than folded: the allocator resolves + // (kind, lifetimeId), so each finds its own slot or nothing. + // + // IDEMPOTENT, like every arm here. This is the REDUNDANT SECOND PATH: the + // client's MGPipeEmitSamplerViewCsoDestroyAndFree emits delete_sampler_view, + // raises this notice and frees the slot, in that order. Whichever of the two + // frees first wins; the other resolves nothing, because the allocator erases + // its lifetimeId -> slot mapping on Free and OnFrontendObjectDestroyed answers + // false for a handle it cannot resolve. This twin owns no driver id at all, so + // even a double release is a pointer reset. + SamplerViewImpl::BackendSamplerViewTable::OnFrontendObjectDestroyed(lifetimeId); + break; + case MG_Pipe::MGPipeKind::VertexElementsCso: + // P3a C-1: this is now the SECOND path, not the only one. The client speaks the + // whole death itself (MGPipeEmitVertexElementsDestroyAndFree: delete the + // applier record, raise this notice, free the slot), because the slot is minted + // client-side on every backend and a backend that installs no death ops - which + // Magma deliberately does not - otherwise leaked the slot and the record per + // VAO for the life of the process. What is left here is the one thing only this + // side can do: drop the driver VAO the twin owns. It is raised while the handle + // still resolves, so OnFrontendObjectDestroyed's shared free (which the other + // five kinds still depend on) is simply the one that gets there first; the + // client's own Free right after it is then a no-op, because Free refuses a slot + // that is no longer live at that generation and the Gen bump rides the next + // handout rather than the free. Double release, no corruption, no abort. + VertexArrayImpl::g_backendVertexArrayObjects.DestroyByLifetimeId(lifetimeId); + break; + default: + // Buffer death crosses as ResourceDestroy (P3a): the catalogue has a call for + // it, so no seventh NotifyStateObjectDestroyed raiser is added. The notice + // exists for kinds that have NO such call - it carries {kind, lifetimeId} and + // not the object, and one entry point serves all of them because the answer is + // the same. The order at the buffer's death is fixed and not negotiable: + // resource_destroy first (the applier clears Live, the backend retires the + // twin), then MGPipeSlots().Free - the allocator erases its lifetimeId -> slot + // mapping on Free, so a notice resolved twice finds nothing the second time. + // On the legacy arm the signal is still BufferBackendOps::OnDestroy. Every + // other kind has no backend twin table here. + break; + } + } + + const MG_State::GLState::StateObjectDeathOps g_glesStateObjectDeathOps = { + .OnDestroyed = OnFrontendStateObjectDestroyed, + }; + } // namespace + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (id), CONTRACT-P5E §4.2 / §4.3: THE TWIN DEATH MOMENT, AND THE ABA ANSWER. + // + // The moment: the apply of `object_death` / the kind's own delete opcode / `resource_destroy` + // - in RING ORDER, i.e. after every verb that named the dying generation and before any + // record that names the slot's next owner - or the server backend's own destruction at Stop + // (InProcessTeardown). `applier_reset` touches neither twins nor object records + // (PipeApply.h's MGPipeApplierReset), so a make-current kills nothing. + // + // The ABA answer, which is what makes "no wait is added at any delete" (§2.7) safe: Gen + // moves ONLY on reuse (SlotAllocator.cpp), the client frees a slot only AFTER its death + // record went out (PipeFill.cpp's NotifyAndFree), and the ring is in order. So at the + // server every record naming {s, g} is applied before object_death{s, g}, which is applied + // before create{s, g+1}. THE ORDERING IS THE ARGUMENT AND THE GEN COMPARE IS THE PROOF: + // * GetOrCreate({s, g+1}) on a live {s, g} entry RE-MINTS - it resets the twin, so the + // successor never inherits the dead object's driver ids (SlotTables.h); + // * FindByHandle({s, g}) after the recycle answers NULL, so a stale handle resolves to + // nothing rather than to its successor's twin; + // * a backward {s, g-1} is REFUSED rather than adopted, so a late record cannot destroy + // the incumbent. + // Which means even a SKIPPED death (an object that never crossed emits none, + // WireTables.cpp) is answered by the forward-Gen reset alone. Pinned by + // DirectGLESSlotTable.AGenerationBehindTheLiveTwinIsRefusedRatherThanAdopted (all three + // clauses, on the table itself) and end to end by the CtWireScenario recycle cases. + Bool ReleaseTwinsForWireObjectDeath(MG_Pipe::MGPipeHandle handle, MG_Pipe::MGPipeKind kind) { + // The handle-keyed twin of the notice switch above, and deliberately NOT that switch: + // the record carried the handle, so no lifetime id is probed and the client's + // allocator - a client-only surface under a transport (CONTRACT-P5C.md §3.1) - is + // never touched from the apply thread. Each arm names its kind's registry GLOBAL for + // the same spelling reason as the notice arms: ReleaseByHandle / + // ReleaseTwinByHandle is static and is answered by every table of the kind that + // exists, this global's own and any by-value copy a fixture or a context reset holds. + switch (kind) { + case MG_Pipe::MGPipeKind::Texture: + return TextureImpl::g_backendTextureObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::Framebuffer: + return FramebufferImpl::g_backendFramebufferObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::Renderbuffer: + return RenderbufferImpl::g_backendRenderbufferObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::SamplerCso: + return SamplerImpl::g_backendSamplerObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::ShaderCso: + return PrgramImpl::g_backendProgramObjects.ReleaseByHandle(handle); + case MG_Pipe::MGPipeKind::SamplerViewCso: + // The notice arm's REDUNDANT SECOND PATH, keyed by the view's handle the same + // way (CONTRACT-P5C.md §5.2): the client's delete_sampler_view may already have + // released the twin, in which case the generation check inside fails and this + // answers false. A false here is idempotency, never a leak - the view twin owns + // no driver id of its own. + return SamplerViewImpl::BackendSamplerViewTable::ReleaseTwinByHandle(handle); + case MG_Pipe::MGPipeKind::VertexElementsCso: + return VertexArrayImpl::g_backendVertexArrayObjects.ReleaseByHandle(handle); + default: + // Buffer's death crosses as resource_destroy (P3a), exactly as the notice + // switch's default arm rules; every other kind has no twin table here. + return false; + } + } +#endif + + // The one sentence that decides the arm, written once so that a test can drive every + // combination of the two knobs and so that bring-up and first-use cannot disagree. + EsprytSlotArmVerdict ClassifyEsprytSlotArm(Bool subsystemBitSet, Bool legacyMemosEnabled) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + if (subsystemBitSet) return EsprytSlotArmVerdict::Handles; + return legacyMemosEnabled ? EsprytSlotArmVerdict::Legacy : EsprytSlotArmVerdict::NoArm; +#else + // The legacy arm is not compiled, so the handle arm is the only arm and neither knob + // can produce an armless configuration. + (void)subsystemBitSet; + (void)legacyMemosEnabled; + return EsprytSlotArmVerdict::Handles; +#endif + } + + EsprytSlotArmVerdict CurrentEsprytSlotArmVerdict() { + return ClassifyEsprytSlotArm( + (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) != 0, + MG_Config::Features.PipeLegacyMemos); + } + + void DiagnoseEsprytSlotArm() { + if (CurrentEsprytSlotArmVerdict() != EsprytSlotArmVerdict::NoArm) return; + // Loud, named, and NOT a stop - see the comment on this function in SlotTables.h for + // why a stop raised from inside EGL bring-up is swallowed into a skipped lane. + MGLOG_E("MGPipe: PipeLegacyMemosDisabled - MOBILEGL_PIPE_PUSH leaves " + "kMGPipeSubsystemEsprytSlots (bit 5) clear and MOBILEGL_PIPE_LEGACY_MEMOS=0 " + "makes the legacy twin registry unreachable, so this context has no twin table " + "arm at all; the first twin lookup will stop the process"); + } + + Bool ResolveEsprytSlotTablesArm() { + // Resolved once and latched by the inline EsprytSlotTablesEnabled() in SlotTables.h: + // the two arms of StateBackendObjectRegistry keep their twins in different containers, + // so an answer that changed mid-run would strand every twin already built (and, for + // the driver ids those twins own, leak them). + // + // This runs at the FIRST TWIN LOOKUP, not at backend bring-up. That is deliberate and + // it is the fix for a lane that went green by skipping: bring-up runs inside + // eglMakeCurrent, which the integration harness pre-flights in a forked child, and a + // child that aborts is reported as "no usable GPU" and skips every scenario. Here the + // stop lands in the caller of the twin lookup - a scenario body, a sync path, a test - + // where ctest reports it as a failure. A process that never looks a twin up never needs + // an arm and is never stopped by this. + const EsprytSlotArmVerdict verdict = CurrentEsprytSlotArmVerdict(); + if (verdict == EsprytSlotArmVerdict::NoArm) { + // The operator asked for the handle arm to be OFF and the legacy arm to be + // unreachable at the same time, which leaves no arm at all. This is a Fatal{}, + // and a Fatal{} in this codebase STOPS (MG_Impl/Pipe/PipeFill.cpp's BadKnob and + // its verify trap are both MGLOG_F + abort). Returning here instead would run + // the very arm the operator disabled and hand back a green result measured on + // it - which is exactly the lever HandleRecycleScenario's arms are selected + // with, so a mis-set A/B would be scored silently against the wrong arm + // (ARCHITECTURE.md 9.6). + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled, \"MOBILEGL_PIPE_PUSH leaves " + "kMGPipeSubsystemEsprytSlots (bit 5) clear and MOBILEGL_PIPE_LEGACY_MEMOS=0 " + "makes the legacy twin registry unreachable, so there is no twin table arm " + "to run\"}"); + std::abort(); + } + if (verdict == EsprytSlotArmVerdict::Legacy) { + return false; + } +#if !MOBILEGL_PIPE_LEGACY_MEMOS + if ((MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemEsprytSlots) == 0) { + // The bit is clear but this build has no legacy twin registry to fall back to, so + // the bit decides nothing. Recorded so a log reader sees the mismatch. + MGLOG_D("MGPipe: kMGPipeSubsystemEsprytSlots is clear but this build has no " + "legacy twin registry; running the handle arm anyway"); + } +#endif + // The notice is only consumable on the handle arm (the legacy registry keys on the + // frontend ADDRESS, which is gone by the time a destructor speaks), so it is installed + // exactly where it can be answered. Once per process, cold. + MG_State::GLState::SetStateObjectDeathOps(&g_glesStateObjectDeathOps); + return true; + } +#endif + Bool VertexStageStorageBlockUsable(Int maxVertexShaderStorageBlocks) { // One block is all the indirect-params view needs, so this is a >= 1 test and not a // budget calculation. Negative is treated as unusable rather than clamped: a driver @@ -439,9 +746,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // layout(binding) reflected at link time, or whatever glUniform1i stored afterwards. // Rewrite every image uniform declaration to that unit so imageLoad/Store hits the // unit the app bound with glBindImageTexture. - String RebindImageUniformsToFrontendUnits( - String source, const SharedPtr& stateProgramObject) { - if (!stateProgramObject || source.find("image") == String::npos) { + String RebindImageUniformsToFrontendUnits(String source, const PrgramImpl::ProgramBuildSource& src) { + // P5e (pg): the SOURCE, not the frontend object. The two reads below - the uniform's + // location and the unit glUniform1i put there - are exactly the pair the record's + // set_program_bindings tail carries, so on the handle arm this bakes the unit the client + // observed rather than one it would have had to dereference a live ProgramObject for. + // The null test the SharedPtr form carried moved to the caller, which returned early on + // a null program long before it reached here. + if (source.find("image") == String::npos) { return source; } static const std::regex imageDeclRegex( @@ -459,12 +771,12 @@ namespace MobileGL::MG_Backend::DirectGLES { std::smatch match; if (std::regex_search(line, match, imageDeclRegex)) { const String name = match[3].str(); - Int location = stateProgramObject->GetUniformLocation(name); + Int location = src.GetUniformLocation(name); if (location < 0) { - location = stateProgramObject->GetUniformLocation(name + "[0]"); + location = src.GetUniformLocation(name + "[0]"); } if (location >= 0) { - const Int unit = stateProgramObject->GetUniformSamplerOrImageUnitIndex(location); + const Int unit = src.GetUniformSamplerOrImageUnitIndex(location); if (unit >= 0) { const String bindingText = "binding = " + std::to_string(unit); if (std::regex_search(line, bindingValueRegex)) { @@ -751,6 +1063,14 @@ namespace MobileGL::MG_Backend::DirectGLES { return DirectGLES::IsBackendContextCurrentOnThisThread(); } +#if MOBILEGL_PIPE_PUSH + // P3a: ONE body per helper, parameterised on where its host bytes and its + // extent come from, so the legacy arm and the handle arm of a push build share + // the three-tier flush, the respecify and the range upload rather than owning a + // copy each - a second copy of the tier ladder is exactly how a tier changes in + // silence. The #else below is the PULL build, whose text must stay byte-identical + // to the pre-P3a one (G1: the pull build gains no symbol and resizes none); it is + // frozen by that gate and retires with the pull path at P13. // (Re)specify backend storage from the shadow copy: glBufferData. // The orphaning point - the ES driver performs the actual rename. // TODO(buffer-pool Phase 2): orphan-on-respecify is NOT yet implemented. @@ -760,31 +1080,34 @@ namespace MobileGL::MG_Backend::DirectGLES { // in-place glBufferData below, to avoid the driver's own rename/stall. Not // pursued yet: glBufferData/glBufferSubData currently sit below profiler // noise, so respecify is not a hot path in the profiled scenes. - void RespecifyStorageNow(GLESBufferResource& resource, BufferObject& bufferObject) { + // The body both arms run. `size`, `usage`, `initialData` and `syncedSerial` are the + // four things the legacy arm reads off the frontend object and the handle arm reads + // off the applier's stored descriptor and the shadow base the call carried; nothing + // else in here differs, so there is ONE glBufferData and one extent rule rather + // than two that can drift apart. + void RespecifyStorageWith(GLESBufferResource& resource, SizeT size, GLenum usage, + const void* initialData, Uint64 syncedSerial) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - const SizeT size = bufferObject.GetSize(); // Read BEFORE the fields below are overwritten: whether this respecify changes // the store's EXTENT is what decides if the indexed-binding shadow still // describes the driver. const Bool extentChanged = !resource.storageInitialized || resource.storageSize != size; - const GLenum usage = MG_Util::ConvertBufferUsageToGLEnum(bufferObject.GetUsage()); BindBufferId(TempBufferTarget, resource.id); - // An orphaning respecify (glBufferData with NULL, content never - // written since) stays a pure NULL reallocation: the driver renames - // the store without a stall and nothing is transferred. Uploading - // the stale shadow here turned Minecraft-style orphaning into a - // full-size synchronized upload. - const void* initialData = - (size > 0 && bufferObject.HasDefinedContent()) ? bufferObject.MappedData() : nullptr; g_GLESFuncs.glBufferData(TempBufferTarget, (GLsizeiptr)size, initialData, usage); + if (MG_Util::PipeStats::Enabled() && initialData != nullptr) { + // An ORPHANING respecify passes NULL and moves nothing, which is exactly + // why the test is on initialData rather than on size. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } resource.storageSize = size; resource.storageInitialized = true; resource.pendingRespecify = false; resource.pendingRanges.clear(); resource.pendingResidentWrites.clear(); - resource.syncedChangeSerial = bufferObject.GetChangeSerial(); + resource.syncedChangeSerial = syncedSerial; // A GROWN store keeps its indexed bindings, and BindBufferBaseCached skips a // rebind whenever the shadow already records this id at that index - so on a // driver that resolves a whole-buffer indexed binding's extent at BIND time @@ -798,21 +1121,115 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - Bool StorageMatches(const GLESBufferResource& resource, const BufferObject& bufferObject) { - return resource.storageInitialized && !resource.pendingRespecify && - resource.storageSize == bufferObject.GetSize(); + void RespecifyStorageNow(GLESBufferResource& resource, BufferObject& bufferObject) { + const SizeT size = bufferObject.GetSize(); + // An orphaning respecify (glBufferData with NULL, content never + // written since) stays a pure NULL reallocation: the driver renames + // the store without a stall and nothing is transferred. Uploading + // the stale shadow here turned Minecraft-style orphaning into a + // full-size synchronized upload. + const void* initialData = + (size > 0 && bufferObject.HasDefinedContent()) ? bufferObject.MappedData() : nullptr; + RespecifyStorageWith(resource, size, MG_Util::ConvertBufferUsageToGLEnum(bufferObject.GetUsage()), + initialData, bufferObject.GetChangeSerial()); } + Bool StorageMatchesSize(const GLESBufferResource& resource, SizeT size) { + return resource.storageInitialized && !resource.pendingRespecify && resource.storageSize == size; + } + Bool StorageMatches(const GLESBufferResource& resource, const BufferObject& bufferObject) { + return StorageMatchesSize(resource, bufferObject.GetSize()); + } - void UploadRangeNow(GLESBufferResource& resource, BufferObject& bufferObject, SizeT start, SizeT end) { +#if MOBILEGL_BUILD_DISAGGREGATED + // ------------------------------------------------------------------------------- + // R-11 - THE SERVER'S OWN COPY OF THE STAGED BYTES. Package v1. + // + // The rule, the evidence and the reason the storage is a side table rather than a + // member of GLESBufferResource are all in MG_Remote/Server/StagedShadow.h. This is + // only the instance and the four call sites. + // + // ONE PER PROCESS, and its copying arm is decided ONCE at first use: the two arms + // hold the authoritative bytes in DIFFERENT places, so an answer that changed + // mid-run would strand every resource already staged - the same reason + // ResolveResourceSubsystemArm latches (Managers.h). Leaked at exit like every other + // MG_Remote singleton (ID-8): ~BufferObject reaches the destroy path from exit + // handlers, after this TU's globals would already be gone. + // ------------------------------------------------------------------------------- + MG_Remote::Server::StagedShadowStore& ServerStaged() { + static MG_Remote::Server::StagedShadowStore& store = + *new MG_Remote::Server::StagedShadowStore( + MG_Config::Transport != MG_Config::TransportMode::Monolith); + return store; + } + + // THE COVERAGE RULE FOR THE THREE-TIER FLUSH DRAIN, ASSERTED BEFORE THE CALL AND NOT + // INSIDE IT. FlushPendingRangesFrom is a G5-pinned body (p3a_untouched_regions.sh's + // PINNED_FUNCTIONS, ID-41): the split arm CALLS it, it does not re-spell it, and it + // does not add a line to it either. Its tier-1 arm - the range-invalidating map - + // copies with its own Memcpy and never reaches UploadRangeFrom, so the one tier that + // DECLARES THE OLD BYTES DEAD is the one tier no later check can see; the only place + // left to say "every queued range is inside the staged coverage" is the instant before + // the drain takes the queue. The clamp is the drain's own (limit = the smaller of the + // frontend size and the backend store; bytes past either end have nowhere to land and + // are not this rule's subject), so the two agree about which bytes are meant. + // + // Fires only for a base that IS this resource's server shadow: RequireCoverage + // answers nothing for the legacy arm's MappedData(), which is valid for the whole + // store. `pendingMutex` is taken here and released before the drain takes it again. + void RequireStagedCoverageForPendingRanges(GLESBufferResource& resource, const Uint8* hostBase, + SizeT frontendSize, const char* site) { + if (hostBase == nullptr) return; + const SizeT limit = std::min(frontendSize, resource.storageSize); + const std::lock_guard lock(resource.pendingMutex); + for (const auto& range : resource.pendingRanges) { + const SizeT end = std::min(range.end, limit); + const SizeT start = std::min(range.start, end); + if (start == end) continue; + ServerStaged().RequireCoverage(&resource, hostBase, start, end, site); + } + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + +// The four hostBytes sites read the same in both builds. The non-split expansion is the +// ORIGINAL EXPRESSION, character for character - `raw - offset` - so a push or verify build +// compiles exactly what it compiled before R-11 and the split arm is the only new behaviour. +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGL_SERVER_STAGED_ADOPT(res, width, bytes, offset, size) \ + ServerStaged().Adopt(&(res), (width), (bytes), (offset), (size)) +#define MGL_SERVER_STAGED_DROP(res) ServerStaged().Drop(&(res)) +#define MGL_SERVER_STAGED_DROP_ALL() ServerStaged().DropAll() +#define MGL_SERVER_STAGED_REQUIRE(res, base, start, end, site) \ + ServerStaged().RequireCoverage(&(res), (base), (start), (end), (site)) +#define MGL_SERVER_STAGED_REQUIRE_PENDING(res, base, frontendSize, site) \ + RequireStagedCoverageForPendingRanges((res), (base), (frontendSize), (site)) +#else +#define MGL_SERVER_STAGED_ADOPT(res, width, bytes, offset, size) \ + (static_cast(bytes) - (offset)) +#define MGL_SERVER_STAGED_DROP(res) ((void)0) +#define MGL_SERVER_STAGED_DROP_ALL() ((void)0) +#define MGL_SERVER_STAGED_REQUIRE(res, base, start, end, site) ((void)0) +#define MGL_SERVER_STAGED_REQUIRE_PENDING(res, base, frontendSize, site) ((void)0) +#endif + + // The host bytes a range upload/flush reads. On the legacy arm the frontend object's + // shadow; on the handle arm the base the last content-carrying call handed over. + void UploadRangeFrom(GLESBufferResource& resource, const Uint8* hostBase, SizeT start, SizeT end) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - if (start >= end) return; + if (start >= end || hostBase == nullptr) return; +#if MOBILEGL_BUILD_DISAGGREGATED + MGL_SERVER_STAGED_REQUIRE(resource, hostBase, start, end, "upload_range"); +#endif BindBufferId(TempBufferTarget, resource.id); g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)start, (GLsizeiptr)(end - start), - bufferObject.MappedData() + start); + hostBase + start); + } + + void UploadRangeNow(GLESBufferResource& resource, BufferObject& bufferObject, SizeT start, SizeT end) { + UploadRangeFrom(resource, bufferObject.MappedData(), start, end); } // Ring machinery shared with the UBO/unpack rings; defined further down in @@ -834,6 +1251,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // sizeable (page-coverable) range to engage, and below it the driver // falls back to waiting out the WAR hazard on the CPU. constexpr SizeT kInvalidateRangeMinBytes = 128u * 1024u; +#if MOBILEGL_PIPE_PUSH + // The push arm's copy of this threshold lives in Managers.h, where a unit case can + // reach it (InvalidateFlushAccessFor). Two constants, one value, and the compiler + // is what keeps them one: the pull arm's FlushPendingRangesNow is byte-frozen + // against 5cb826b0 (ID-15), so the constant it reads may not move to a header. + static_assert(kInvalidateRangeMinBytes == kEsprytInvalidateRangeMinBytes, + "the two arms of the three-tier ladder must use the same tier-1 threshold"); +#endif // Push every queued range of `resource` from the shadow into the backend // store, without ever letting a driver resolve the WAR hazard against @@ -863,7 +1288,27 @@ namespace MobileGL::MG_Backend::DirectGLES { // and collapsing a scattered flush into its union re-copied nearly whole // chunk-mesh arenas every frame. // The caller owns syncedChangeSerial; this only drains the queue. - void FlushPendingRangesNow(GLESBufferResource& resource, BufferObject& bufferObject) { + // + // ONE body for both arms, and deliberately so: the three-tier decision (whole-buffer + // orphan map / >= 128 KiB range-invalidating map / staged ring copy) is what the MC + // 26.3 p99 depends on, and a second copy of it for the handle arm is exactly how a + // tier silently changes. The arms differ only in where `hostBase` and `frontendSize` + // come from. + // + // R-11'S PARAMETER, AND IT IS A PARAMETER RATHER THAN AN ASSUMPTION (P5 b1). + // `hostBase` is re-read at every use today precisely because a shadow resize or an + // adoption moves what an earlier base pointed at. Under split the server may hold + // no pointer into the client's shadow at all, so the base becomes a SNAPSHOT taken + // into SEG_STAGE at emission - and a snapshot covers a RANGE, not the store. The + // two extra arguments say which range `hostBase` is good for; the default is the + // whole store, which is exactly what a live shadow is, so every caller today is + // byte-identical. The moment w1 passes a real snapshot extent, tier 1's widening + // refusal below stops being unreachable and a too-narrow snapshot is named instead + // of silently clobbering GPU-written bytes with stale ones. + constexpr SizeT kHostBaseCoversWholeStore = ~static_cast(0); + void FlushPendingRangesFrom(GLESBufferResource& resource, const Uint8* hostBase, SizeT frontendSize, + SizeT hostBaseFrom = 0, + SizeT hostBaseTo = kHostBaseCoversWholeStore) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif @@ -874,10 +1319,15 @@ namespace MobileGL::MG_Backend::DirectGLES { ranges = std::move(resource.pendingRanges); resource.pendingRanges.clear(); } + // No shadow to read from: only reachable on the handle arm, where the base + // arrives with the call, and only for a resource that queued a range before any + // content-carrying call named one. The queue is already drained, so the next + // full re-upload is what puts the store right. + if (hostBase == nullptr) return; // Clamp against BOTH extents: the readback flush may run while the // frontend size and the backend store disagree (a pending respecify // resolves that later; bytes past either end have nowhere to land). - const SizeT limit = std::min(bufferObject.GetSize(), resource.storageSize); + const SizeT limit = std::min(frontendSize, resource.storageSize); const Bool mapUsable = !MG_Config::Features.EsprytDisableInvalidateFlush && g_GLESFuncs.glMapBufferRange && g_GLESFuncs.glUnmapBuffer; const Bool ringUsable = UploadRingUsableNow(); @@ -886,6 +1336,13 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT start = std::min(range.start, end); const SizeT size = end - start; if (size == 0) continue; + if (MG_Util::PipeStats::Enabled()) { + // Counted once per queued range, before the three delivery shapes + // below diverge: all three move exactly these bytes, and it is the + // byte count - not the shape - that sizes SEG_STAGE. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } // The invalidating map's fast path is SHAPE-dependent on this Mali // driver: a whole-buffer invalidation renames the store outright, // and a large range gets fresh pages - but a small unaligned range @@ -899,16 +1356,32 @@ namespace MobileGL::MG_Backend::DirectGLES { // shadow's to rewrite. Widening to page bounds looked free and was // not - the widened bytes clobbered GPU-written data (an SSBO // counter beside the app's SubData) with the stale shadow. - const Bool wholeBuffer = start == 0 && end == limit && limit == resource.storageSize; - if (mapUsable && (wholeBuffer || size >= kInvalidateRangeMinBytes)) { + // The extent the bytes behind `hostBase` are actually good for, clamped + // to the store. With the default it IS [start, end), so the refusal below + // cannot fire and the ladder is unchanged; with a real SEG_STAGE snapshot + // it is the snapshot's window and a disagreement drops this range to the + // staging ring instead of letting a widened INVALIDATE_RANGE declare bytes + // dead that nothing is about to rewrite. + const SizeT coveredFrom = hostBaseFrom > start ? hostBaseFrom : start; + const SizeT coveredTo = hostBaseTo < end ? hostBaseTo : end; +#if MOBILEGL_PIPE_VERIFY + if (coveredFrom != start || coveredTo != end) { + MGLOG_E_ONCE("MGPipe: Fatal{StageSnapshotTooNarrow} flush_pending_ranges: the " + "staged bytes cover [%zu, %zu) and the queued range is [%zu, %zu) " + "- tiers 2 and 3 would copy from outside the snapshot", + hostBaseFrom, hostBaseTo, start, end); + } +#endif + const GLbitfield access = + mapUsable ? InvalidateFlushAccessFor(start, end, coveredFrom, coveredTo, limit, + resource.storageSize) + : 0u; + if (access != 0) { BindBufferId(TempBufferTarget, resource.id); - const GLbitfield access = - GL_MAP_WRITE_BIT | - (wholeBuffer ? GL_MAP_INVALIDATE_BUFFER_BIT : GL_MAP_INVALIDATE_RANGE_BIT); void* dst = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)start, (GLsizeiptr)size, access); if (dst) { - Memcpy(dst, bufferObject.MappedData() + start, size); + Memcpy(dst, hostBase + start, size); g_GLESFuncs.glUnmapBuffer(TempBufferTarget); continue; } @@ -916,17 +1389,46 @@ namespace MobileGL::MG_Backend::DirectGLES { SizeT ringOffset = 0; if (ringUsable && size <= kUploadRingMaxBytes && RingAllocate(g_uploadRing, size, ringOffset)) { - Memcpy(g_uploadRing.store.mappedPtr + ringOffset, bufferObject.MappedData() + start, size); + Memcpy(g_uploadRing.store.mappedPtr + ringOffset, hostBase + start, size); BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id); BindBufferId(GL_COPY_WRITE_BUFFER, resource.id); g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, (GLintptr)ringOffset, (GLintptr)start, (GLsizeiptr)size); } else { - UploadRangeNow(resource, bufferObject, start, end); + UploadRangeFrom(resource, hostBase, start, end); } } } + // THE TWO-ARM SHAPE, AND BOTH ARMS ARE HASHED FROM HERE ON (ID-15, which + // supersedes ID-13's "FlushPendingRangesNow is defined exactly once, outside any + // `#if`"). + // + // This file has TWO three-tier drains, and exactly one of them is compiled into any + // given build: `FlushPendingRangesFrom` above, inside `#if MOBILEGL_PIPE_PUSH`, is + // what a PUSH build runs - both of its call sites, the legacy one and + // Ops_H_Readback, reach it - and `FlushPendingRangesNow` inside the `#else` below, + // byte-identical to 5cb826b0, is what a PULL build runs. A push build compiles no + // FlushPendingRangesNow at all. + // + // NO FORWARDER HERE, which is what forced the two-arm shape: G5's extractor is + // preprocessor-blind, so a `FlushPendingRangesNow` that forwarded onto the ladder + // above would leave TWO definitions of that one name in the file and the gate would + // exit 2 - a gate that cannot run - rather than compare anything. (The handle arm + // cannot call FlushPendingRangesNow itself either: its signature takes a + // BufferObject&, and having no frontend object to offer is the whole point of the + // conversion.) + // + // WHAT ID-15 ADDS. The shape above is sound, but a gate keyed on the NAME + // `FlushPendingRangesNow` alone would from here on protect only the text the + // shipping build never compiles: a tier-threshold or map-access-bit edit made in + // FlushPendingRangesFrom - the ladder that actually runs, and the one MC 26.3's p99 + // depends on - would satisfy both G1 (the pull text did not move) and G5 (the pull + // name still hashes the same). So scripts/p3a_untouched_regions.sh hashes BOTH + // names: eleven functions, the pull ladder compared against the P3a base ref and + // this one against a sha pinned at 3e298c9a. Neither ladder may drift, and neither + // may drift AWAY FROM THE OTHER without the gate saying so. + // Land the app bytes queued for an ADOPTED store on the GPU timeline: staged // into the upload ring and delivered by glCopyBufferSubData. The destination // is the IMMUTABLE persistent store, which the driver can neither rename nor @@ -936,7 +1438,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // reading the old vertex data: one-frame wrong geometry during fast camera // movement.) Fallback: direct glBufferSubData - the adopted store carries // DYNAMIC_STORAGE, and immutability again forbids the whole-store ghost. - void DrainResidentWritesNow(GLESBufferResource& resource, BufferObject& bufferObject) { + // + // It never read the frontend object (the bytes are on the resource's own queue and + // the extent is the backend store's), so it takes none: the handle arm calls exactly + // this function with exactly these semantics. + void DrainResidentWritesNow(GLESBufferResource& resource) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif @@ -953,6 +1459,10 @@ namespace MobileGL::MG_Backend::DirectGLES { if (write.offset >= limit) continue; const SizeT size = std::min(write.bytes.size(), limit - write.offset); if (size == 0) continue; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } SizeT ringOffset = 0; if (ringUsable && size <= kUploadRingMaxBytes && RingAllocate(g_uploadRing, size, ringOffset)) { @@ -970,98 +1480,342 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - // EXT_buffer_storage bit values (same numeric values as the desktop ARB - // tokens); defined locally so this compiles regardless of which GLES headers - // expose the EXT tokens. - constexpr GLbitfield kMapPersistentBit = 0x0040; - constexpr GLbitfield kMapCoherentBit = 0x0080; - constexpr GLbitfield kDynamicStorageBit = 0x0100; - - // Zero-copy persistent map: back the buffer with real immutable, - // persistently+coherently mapped GL storage (EXT_buffer_storage) and hand the - // app that mapped pointer (adopted by the frontend PipeResource). Returns - // nullptr when the extension is unavailable or the context is not current, in - // which case the frontend keeps its CPU-shadow model. Idempotent. - void* Ops_AcquirePersistentMap(BufferObject& bufferObject) { - if (!CanTouchGLNow() || !g_GLESFuncs.glBufferStorageEXT || !g_GLESFuncs.glMapBufferRange || - !g_GLESFuncs.glGenBuffers) { - return nullptr; - } + // The two-argument spelling the legacy arm's call sites use, kept so those sites + // are the SAME text in both builds (G1). The object was never read. + void DrainResidentWritesNow(GLESBufferResource& resource, BufferObject& bufferObject) { + (void)bufferObject; + DrainResidentWritesNow(resource); + } +#else + // (Re)specify backend storage from the shadow copy: glBufferData. + // The orphaning point - the ES driver performs the actual rename. + // TODO(buffer-pool Phase 2): orphan-on-respecify is NOT yet implemented. + // When the current id is BUSY (lastUseFrameSerial > CompletedFrameSerial()) + // && !persistentMapped && !noOrphan, express the orphan as an id-swap + // (retire the busy id into the pool, bind a fresh/pooled id) instead of the + // in-place glBufferData below, to avoid the driver's own rename/stall. Not + // pursued yet: glBufferData/glBufferSubData currently sit below profiler + // noise, so respecify is not a hot path in the profiled scenes. + void RespecifyStorageNow(GLESBufferResource& resource, BufferObject& bufferObject) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif const SizeT size = bufferObject.GetSize(); - if (size == 0) return nullptr; - - auto* resource = static_cast(bufferObject.GetBackendResource().get()); - if (!resource) { - auto created = MakeShared(); - resource = created.get(); - bufferObject.SetBackendResource(std::move(created)); + // Read BEFORE the fields below are overwritten: whether this respecify changes + // the store's EXTENT is what decides if the indexed-binding shadow still + // describes the driver. + const Bool extentChanged = !resource.storageInitialized || resource.storageSize != size; + const GLenum usage = MG_Util::ConvertBufferUsageToGLEnum(bufferObject.GetUsage()); + BindBufferId(TempBufferTarget, resource.id); + // An orphaning respecify (glBufferData with NULL, content never + // written since) stays a pure NULL reallocation: the driver renames + // the store without a stall and nothing is transferred. Uploading + // the stale shadow here turned Minecraft-style orphaning into a + // full-size synchronized upload. + const void* initialData = + (size > 0 && bufferObject.HasDefinedContent()) ? bufferObject.MappedData() : nullptr; + g_GLESFuncs.glBufferData(TempBufferTarget, (GLsizeiptr)size, initialData, usage); + if (MG_Util::PipeStats::Enabled() && initialData != nullptr) { + // An ORPHANING respecify passes NULL and moves nothing, which is exactly + // why the test is on initialData rather than on size. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); } - // Before the generation is stamped, not after: everything on the resource - // describes a context that is gone, and the idempotency check below would - // otherwise hand the caller the dead context's mapped pointer. - if (resource->contextGeneration != g_bufferContextGeneration) { - resource->id = 0; - resource->persistentMapped = false; - resource->persistentPtr = nullptr; - resource->immutableStorage = false; - resource->storageInitialized = false; - resource->storageSize = 0; + resource.storageSize = size; + resource.storageInitialized = true; + resource.pendingRespecify = false; + resource.pendingRanges.clear(); + resource.pendingResidentWrites.clear(); + resource.syncedChangeSerial = bufferObject.GetChangeSerial(); + // A GROWN store keeps its indexed bindings, and BindBufferBaseCached skips a + // rebind whenever the shadow already records this id at that index - so on a + // driver that resolves a whole-buffer indexed binding's extent at BIND time + // (Adreno does; Mali does not) the shader keeps seeing the old, smaller range: + // stores past it are dropped and loads return zero. Forget what the shadow + // claims for this id so the next SyncBufferBindingPoints issues the bind for + // real. Only when the extent actually moved: an orphaning respecify at the same + // size is Minecraft's per-frame hot path and its bindings are still exact. + if (extentChanged) { + InvalidateIndexedBufferBindingShadowsForId(resource.id); } - resource->contextGeneration = g_bufferContextGeneration; + } - if (resource->persistentMapped && resource->persistentPtr && resource->storageSize == size) { - return resource->persistentPtr; // idempotent - } + Bool StorageMatches(const GLESBufferResource& resource, const BufferObject& bufferObject) { + return resource.storageInitialized && !resource.pendingRespecify && + resource.storageSize == bufferObject.GetSize(); + } - // Need a fresh id: glBufferStorage fails on a buffer that already has - // immutable storage, and any prior mutable store is replaced anyway. - if (resource->id != 0) { - NoteBufferIdDeleted(resource->id); - // Driver VAOs may have this id baked into attribute/element bindings - // keyed on frontend versions this re-mint does not move. - ++g_bufferBackendIdGeneration; - g_GLESFuncs.glDeleteBuffers(1, &resource->id); - resource->id = 0; - resource->immutableStorage = false; - } - g_GLESFuncs.glGenBuffers(1, &resource->id); - if (resource->id == 0) return nullptr; - // Seed from the shadow (MappedData() is still the shadow: the frontend - // adopts and drops it only after this returns). - BindBufferId(TempBufferTarget, resource->id); - const void* initial = bufferObject.MappedData(); - g_GLESFuncs.glBufferStorageEXT(TempBufferTarget, static_cast(size), initial, - GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit | - kDynamicStorageBit); - // Set as soon as the store exists, not once the map succeeds: the failure - // path below leaves this id holding immutable storage, and whoever touches - // it next has to know that glBufferData cannot redefine it. - resource->immutableStorage = true; - void* ptr = g_GLESFuncs.glMapBufferRange(TempBufferTarget, 0, static_cast(size), - GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit); - if (!ptr) { - MGLOG_E_ONCE("Ops_AcquirePersistentMap: glMapBufferRange(persistent) failed for buffer %u", - resource->id); - resource->persistentMapped = false; - resource->persistentPtr = nullptr; - return nullptr; - } - resource->persistentPtr = ptr; - resource->persistentMapped = true; - resource->storageSize = size; - resource->storageInitialized = true; - resource->pendingRespecify = false; - { - const std::lock_guard lock(resource->pendingMutex); - resource->pendingRanges.clear(); - resource->pendingResidentWrites.clear(); - } - resource->syncedChangeSerial = bufferObject.GetChangeSerial(); - return ptr; + + void UploadRangeNow(GLESBufferResource& resource, BufferObject& bufferObject, SizeT start, SizeT end) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + if (start >= end) return; + BindBufferId(TempBufferTarget, resource.id); + g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)start, (GLsizeiptr)(end - start), + bufferObject.MappedData() + start); } - void Ops_Respecify(BufferObject& bufferObject) { + // Ring machinery shared with the UBO/unpack rings; defined further down in + // this same unnamed namespace. + Bool RingAllocate(PersistentRing& ring, SizeT size, SizeT& outOffset); + Bool RingAvailable(PersistentRing& ring); + + // True when a pending-range flush can go through the staging ring right + // now: kill switch off, the ES copy entry point resolved, and the ring's + // own availability gate (EXT_buffer_storage + fences + live context) up. + Bool UploadRingUsableNow() { + if (MG_Config::Features.EsprytDisableUploadRing) return false; + if (!g_GLESFuncs.glCopyBufferSubData) return false; + return RingAvailable(g_uploadRing); + } + + // A partial range below this goes through the staging ring instead of a + // range-invalidating map: the map's page-substitution fast path needs a + // sizeable (page-coverable) range to engage, and below it the driver + // falls back to waiting out the WAR hazard on the CPU. + constexpr SizeT kInvalidateRangeMinBytes = 128u * 1024u; + + // Push every queued range of `resource` from the shadow into the backend + // store, without ever letting a driver resolve the WAR hazard against + // in-flight frames at the WHOLE BUFFER's expense. Three tiers: + // + // 1. glMapBufferRange(WRITE | INVALIDATE_RANGE) + memcpy. The entire + // mapped range is rewritten from the authoritative shadow, so + // declaring its old bytes dead is exact - and it lets the driver + // swap fresh pages in for JUST that range. This is the only tier + // whose cost scales with the RANGE on this Mali driver: both the + // immediate glBufferSubData (pre-queueing) and a staged + // glCopyBufferSubData into a busy MUTABLE store ghost the whole + // destination with a worker-thread memcpy - Minecraft 26.3 streams + // ~1MB section meshes into 128MB arenas about nine times a frame + // during a camera pan, and 9 x 128MB of ghosting per frame is + // ~380ms, the measured 2-4 fps. (Backing the arenas with immutable + // stores also kills the ghost, but eagerly commits every arena's + // full extent - +hundreds of MB - which LMK'd the whole device.) + // 2. The staging ring + glCopyBufferSubData: the copy is ordered on + // the GPU timeline, no CPU wait (MOBILEGL_ESPRYT_DISABLE_INVALIDATE_FLUSH + // forces this tier as the map path's negative control). + // 3. Direct glBufferSubData (potentially stalling) when neither the + // map entry points nor the ring exist. + // + // The ranges are flushed AS QUEUED (VecRange1D::Add already merges + // near-adjacent ones): bytes, not flush calls, are the cost axis here, + // and collapsing a scattered flush into its union re-copied nearly whole + // chunk-mesh arenas every frame. + // The caller owns syncedChangeSerial; this only drains the queue. + void FlushPendingRangesNow(GLESBufferResource& resource, BufferObject& bufferObject) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + VecRange1D ranges; + { + const std::lock_guard lock(resource.pendingMutex); + if (resource.pendingRanges.empty()) return; + ranges = std::move(resource.pendingRanges); + resource.pendingRanges.clear(); + } + // Clamp against BOTH extents: the readback flush may run while the + // frontend size and the backend store disagree (a pending respecify + // resolves that later; bytes past either end have nowhere to land). + const SizeT limit = std::min(bufferObject.GetSize(), resource.storageSize); + const Bool mapUsable = !MG_Config::Features.EsprytDisableInvalidateFlush && + g_GLESFuncs.glMapBufferRange && g_GLESFuncs.glUnmapBuffer; + const Bool ringUsable = UploadRingUsableNow(); + for (const auto& range : ranges) { + const SizeT end = std::min(range.end, limit); + const SizeT start = std::min(range.start, end); + const SizeT size = end - start; + if (size == 0) continue; + if (MG_Util::PipeStats::Enabled()) { + // Counted once per queued range, before the three delivery shapes + // below diverge: all three move exactly these bytes, and it is the + // byte count - not the shape - that sizes SEG_STAGE. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } + // The invalidating map's fast path is SHAPE-dependent on this Mali + // driver: a whole-buffer invalidation renames the store outright, + // and a large range gets fresh pages - but a small unaligned range + // of a busy store makes the map WAIT (osup_sync_object_wait, ~9% + // of a Minecraft 26.3 replay). So: whole buffer -> orphan-map; + // large range -> range-invalidating map; small range -> the staged + // ring copy, whose worst case (a whole-destination ghost) is only + // ever the small destination itself. + // + // The map covers EXACTLY the queued range: only those bytes are the + // shadow's to rewrite. Widening to page bounds looked free and was + // not - the widened bytes clobbered GPU-written data (an SSBO + // counter beside the app's SubData) with the stale shadow. + const Bool wholeBuffer = start == 0 && end == limit && limit == resource.storageSize; + if (mapUsable && (wholeBuffer || size >= kInvalidateRangeMinBytes)) { + BindBufferId(TempBufferTarget, resource.id); + const GLbitfield access = + GL_MAP_WRITE_BIT | + (wholeBuffer ? GL_MAP_INVALIDATE_BUFFER_BIT : GL_MAP_INVALIDATE_RANGE_BIT); + void* dst = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)start, + (GLsizeiptr)size, access); + if (dst) { + Memcpy(dst, bufferObject.MappedData() + start, size); + g_GLESFuncs.glUnmapBuffer(TempBufferTarget); + continue; + } + } + SizeT ringOffset = 0; + if (ringUsable && size <= kUploadRingMaxBytes && + RingAllocate(g_uploadRing, size, ringOffset)) { + Memcpy(g_uploadRing.store.mappedPtr + ringOffset, bufferObject.MappedData() + start, size); + BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id); + BindBufferId(GL_COPY_WRITE_BUFFER, resource.id); + g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, + (GLintptr)ringOffset, (GLintptr)start, (GLsizeiptr)size); + } else { + UploadRangeNow(resource, bufferObject, start, end); + } + } + } + + // Land the app bytes queued for an ADOPTED store on the GPU timeline: staged + // into the upload ring and delivered by glCopyBufferSubData. The destination + // is the IMMUTABLE persistent store, which the driver can neither rename nor + // ghost, so the copy is plain job ordering - after every in-flight reader, + // before the next consumer - which is exactly glBufferSubData's contract. + // (The in-place host write these bytes replaced tore the frames still + // reading the old vertex data: one-frame wrong geometry during fast camera + // movement.) Fallback: direct glBufferSubData - the adopted store carries + // DYNAMIC_STORAGE, and immutability again forbids the whole-store ghost. + void DrainResidentWritesNow(GLESBufferResource& resource, BufferObject& bufferObject) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + Vector writes; + { + const std::lock_guard lock(resource.pendingMutex); + if (resource.pendingResidentWrites.empty()) return; + writes = std::move(resource.pendingResidentWrites); + resource.pendingResidentWrites.clear(); + } + const SizeT limit = resource.storageSize; + const Bool ringUsable = UploadRingUsableNow(); + for (const auto& write : writes) { + if (write.offset >= limit) continue; + const SizeT size = std::min(write.bytes.size(), limit - write.offset); + if (size == 0) continue; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } + SizeT ringOffset = 0; + if (ringUsable && size <= kUploadRingMaxBytes && + RingAllocate(g_uploadRing, size, ringOffset)) { + Memcpy(g_uploadRing.store.mappedPtr + ringOffset, write.bytes.data(), size); + BindBufferId(GL_COPY_READ_BUFFER, g_uploadRing.store.id); + BindBufferId(GL_COPY_WRITE_BUFFER, resource.id); + g_GLESFuncs.glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, + (GLintptr)ringOffset, (GLintptr)write.offset, + (GLsizeiptr)size); + } else { + BindBufferId(TempBufferTarget, resource.id); + g_GLESFuncs.glBufferSubData(TempBufferTarget, (GLintptr)write.offset, (GLsizeiptr)size, + write.bytes.data()); + } + } + } +#endif // MOBILEGL_PIPE_PUSH + + // EXT_buffer_storage bit values (same numeric values as the desktop ARB + // tokens); defined locally so this compiles regardless of which GLES headers + // expose the EXT tokens. + constexpr GLbitfield kMapPersistentBit = 0x0040; + constexpr GLbitfield kMapCoherentBit = 0x0080; + constexpr GLbitfield kDynamicStorageBit = 0x0100; + + // Zero-copy persistent map: back the buffer with real immutable, + // persistently+coherently mapped GL storage (EXT_buffer_storage) and hand the + // app that mapped pointer (adopted by the frontend PipeResource). Returns + // nullptr when the extension is unavailable or the context is not current, in + // which case the frontend keeps its CPU-shadow model. Idempotent. + void* Ops_AcquirePersistentMap(BufferObject& bufferObject) { + if (!CanTouchGLNow() || !g_GLESFuncs.glBufferStorageEXT || !g_GLESFuncs.glMapBufferRange || + !g_GLESFuncs.glGenBuffers) { + return nullptr; + } + const SizeT size = bufferObject.GetSize(); + if (size == 0) return nullptr; + + auto* resource = static_cast(bufferObject.GetBackendResource().get()); + if (!resource) { + auto created = MakeShared(); + resource = created.get(); + bufferObject.SetBackendResource(std::move(created)); + } + // Before the generation is stamped, not after: everything on the resource + // describes a context that is gone, and the idempotency check below would + // otherwise hand the caller the dead context's mapped pointer. + if (resource->contextGeneration != g_bufferContextGeneration) { + resource->id = 0; + resource->persistentMapped = false; + resource->persistentPtr = nullptr; + resource->immutableStorage = false; + resource->storageInitialized = false; + resource->storageSize = 0; + } + resource->contextGeneration = g_bufferContextGeneration; + + if (resource->persistentMapped && resource->persistentPtr && resource->storageSize == size) { + return resource->persistentPtr; // idempotent + } + + // Need a fresh id: glBufferStorage fails on a buffer that already has + // immutable storage, and any prior mutable store is replaced anyway. + if (resource->id != 0) { + NoteBufferIdDeleted(resource->id); + // Driver VAOs may have this id baked into attribute/element bindings + // keyed on frontend versions this re-mint does not move. + ++g_bufferBackendIdGeneration; + g_GLESFuncs.glDeleteBuffers(1, &resource->id); + resource->id = 0; + resource->immutableStorage = false; + } + g_GLESFuncs.glGenBuffers(1, &resource->id); + if (resource->id == 0) return nullptr; + + // Seed from the shadow (MappedData() is still the shadow: the frontend + // adopts and drops it only after this returns). + BindBufferId(TempBufferTarget, resource->id); + const void* initial = bufferObject.MappedData(); + g_GLESFuncs.glBufferStorageEXT(TempBufferTarget, static_cast(size), initial, + GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit | + kDynamicStorageBit); + // Set as soon as the store exists, not once the map succeeds: the failure + // path below leaves this id holding immutable storage, and whoever touches + // it next has to know that glBufferData cannot redefine it. + resource->immutableStorage = true; + void* ptr = g_GLESFuncs.glMapBufferRange(TempBufferTarget, 0, static_cast(size), + GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit); + if (!ptr) { + MGLOG_E_ONCE("Ops_AcquirePersistentMap: glMapBufferRange(persistent) failed for buffer %u", + resource->id); + resource->persistentMapped = false; + resource->persistentPtr = nullptr; + return nullptr; + } + resource->persistentPtr = ptr; + resource->persistentMapped = true; + resource->storageSize = size; + resource->storageInitialized = true; + resource->pendingRespecify = false; + { + const std::lock_guard lock(resource->pendingMutex); + resource->pendingRanges.clear(); + resource->pendingResidentWrites.clear(); + } + resource->syncedChangeSerial = bufferObject.GetChangeSerial(); + return ptr; + } + + void Ops_Respecify(BufferObject& bufferObject) { auto* resource = ResourceOf(bufferObject); if (!resource) return; // lazy: EnsureBufferResource full-uploads on creation // The frontend hands an adopted mapping back before it redefines the store @@ -1252,7 +2006,14 @@ namespace MobileGL::MG_Backend::DirectGLES { // Queued app writes must land in the backend store before it is read // back, or the writeback below would revert them in the shadow. +#if MOBILEGL_PIPE_PUSH + // The shared ladder, by ID-11: a push build has no FlushPendingRangesNow (see + // the note at its would-be forwarder), so this arm and the handle arm call the + // one body between them. + FlushPendingRangesFrom(*resource, bufferObject.MappedData(), bufferObject.GetSize()); +#else FlushPendingRangesNow(*resource, bufferObject); +#endif BindBufferId(TempBufferTarget, resource->id); void* mapped = g_GLESFuncs.glMapBufferRange(TempBufferTarget, 0, static_cast(size), @@ -1304,35 +2065,721 @@ namespace MobileGL::MG_Backend::DirectGLES { Ops_Respecify(bufferObject); BumpBufferMutationEpoch(); } - void Ops_SubDataTracked(BufferObject& bufferObject, SizeT offset, SizeT size) { - Ops_SubData(bufferObject, offset, size); + void Ops_SubDataTracked(BufferObject& bufferObject, SizeT offset, SizeT size) { + Ops_SubData(bufferObject, offset, size); + BumpBufferMutationEpoch(); + } + void Ops_ResidentSubDataTracked(BufferObject& bufferObject, SizeT offset, DataPtr data) { + Ops_ResidentSubData(bufferObject, offset, data); + BumpBufferMutationEpoch(); + } + void Ops_FlushMappedRangeTracked(BufferObject& bufferObject, Range1D range, + Flags appAccess) { + Ops_FlushMappedRange(bufferObject, range, appAccess); + BumpBufferMutationEpoch(); + } + void Ops_OnDestroyTracked(SharedPtr&& resource) { + Ops_OnDestroy(std::move(resource)); + BumpBufferMutationEpoch(); + } + void* Ops_AcquirePersistentMapTracked(BufferObject& bufferObject) { + void* result = Ops_AcquirePersistentMap(bufferObject); + // Bump even on decline: the frontend still enters a persistent map the + // per-draw probes must start seeing (IsMapped-driven range pushes). + BumpBufferMutationEpoch(); + return result; + } + void Ops_ReadbackFromGpuTracked(BufferObject& bufferObject) { + Ops_ReadbackFromGpu(bufferObject); + BumpBufferMutationEpoch(); + } + +#if MOBILEGL_PIPE_PUSH + // ---- P3a: the same seven ops plus the create/unmap pair, BY HANDLE ------------ + // + // Every body below is its Ops_* counterpart above with exactly the substitutions + // D-A2's table names and nothing else: the frontend object's GetSize() / GetUsage() + // / HasDefinedContent() / MappedData() / GetChangeSerial() become the applier + // record's Desc.Width / Desc.Usage / Desc.HasDefinedContent, the shadow base the + // call carried, and the record's server-owned Serial. Every branch survives - + // pendingRespecify early-out, the off-thread queue, the adopted zero-copy stamp, + // the upload-ring kill switch, the Mali WAR-stall queue-only default. + // + // NOTHING here reads a frontend type. The one thing a handle op cannot do is ask + // an object for its shadow, which is why GLESBufferResource::hostBytes exists: the + // three content-carrying calls hand the base over and the later drains read it. + + // The applier's record for this resource, or null when the client never created it + // (or created it into a slot that has since been recycled). + const MG_Pipe::MGPipeResourceRecord* ResourceRecordOf(MG_Pipe::MGPipeHandle res) { + if (MG_Pipe::MGPipeHandleIsNull(res)) return nullptr; + const auto& records = MG_Pipe::MGPipeApplier().Resources; + if (res.Slot >= records.size()) return nullptr; + const auto& record = records[res.Slot]; + if (!record.Live || record.Gen != res.Gen) return nullptr; + return &record; + } + + // The server-owned MGGen this backend mirrors in syncedChangeSerial. Zero for a + // resource with no record, which is the same "never synced" answer a frontend + // change serial of zero gave. + Uint64 ResourceSerialOf(MG_Pipe::MGPipeHandle res) { + const auto* record = ResourceRecordOf(res); + return record != nullptr ? record->Serial : 0; + } + SizeT ResourceWidthOf(MG_Pipe::MGPipeHandle res) { + const auto* record = ResourceRecordOf(res); + return record != nullptr ? static_cast(record->Desc.Width) : 0; + } +#if MOBILEGL_BUILD_DISAGGREGATED + // M-3's rule for the two WHOLE-STORE readers (v1 round 3). The descriptor is the + // application's own statement about the store: HasDefinedContent set means it SUPPLIED + // the content (glBufferData(size, data)), which under split arrives as resource_subdata + // records behind the respecify (table 1 row 19) - so a coverage gap at the draw is a + // MISSING RECORD and the zero-fill past the coverage is not the application's bytes. + // Clear means it ORPHANED the store (glBufferData(size, NULL), glBufferStorage(NULL)): + // every byte it has not staged since is UNDEFINED by its own declaration, the streaming + // idiom (orphan, partial glBufferSubData, draw) is the ordinary case, and uploading the + // shadow's zero-fill for the rest is exactly what the monolith arm uploads from + // MappedData(). Round 2 refused both shapes and aborted six LargeArenaAdoption / + // ResourceSubsystemControl entries on the joint by name (v1-v3.md 6). + Bool ResourceContentIsDeclared(MG_Pipe::MGPipeHandle res) { + const auto* record = ResourceRecordOf(res); + return record != nullptr && record->Desc.HasDefinedContent != 0; + } +#endif + + void Ops_H_Create(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPResourceDesc& desc) { + (void)res; + (void)desc; + // Nothing, and that is the row D-A2 writes: storage is defined lazily by the + // first resource_respecify, and the ensure path already tolerates a resource + // with none. Minting the twin here would only move the allocation earlier. + } + + void Ops_H_Respecify(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPResourceDesc& desc, + const void* initialBytes) { + auto* resource = FindBufferResourceForHandle(res); + if (resource != nullptr) { + // A respecify's companion pointer IS the shadow base (offset 0) - and an + // ORPHANING one (glBufferData with NULL) carries HasDefinedContent clear and + // therefore no pointer at all. That is also the call that RESIZES the + // client's shadow (PipeResource::ResizeShadow is reserve + resize, so a grow + // past the reserve reallocates and frees the block a previous call's base + // pointed into), so the recorded base is dead exactly when no new one + // arrives. FORGET it here rather than leave a pointer nothing refreshes: + // every reader below treats a null base as "no bytes to move", which is the + // honest answer, and the ensure path re-reads the live base from the + // frontend object it still holds. + if (desc.HasDefinedContent != 0 && initialBytes != nullptr) { + // R-11: in monolith this is the client's shadow base, unchanged; under + // split it is copied into server-owned storage first. A respecify's + // companion pointer is the base at offset 0 and covers the whole store. + // Under split it is ALWAYS null (contract table 1 row 19 - + // initialBytes does not cross, and the content arrives as + // resource_subdata records right behind this record), so in practice + // this arm is the monolith one and the else arm is the split one. + resource->hostBytes = MGL_SERVER_STAGED_ADOPT(*resource, static_cast(desc.Width), + initialBytes, 0, + static_cast(desc.Width)); + } else { + MGL_SERVER_STAGED_DROP(*resource); + resource->hostBytes = nullptr; + } + } + if (!resource) return; // lazy: the ensure path full-uploads on creation + if (resource->immutableStorage) { + resource->persistentMapped = false; + resource->persistentPtr = nullptr; + if (resource->id != 0 && CanTouchGLNow() && + resource->contextGeneration == g_bufferContextGeneration) { + NoteBufferIdDeleted(resource->id); + // Frontend VAO bindings survive respecification; force their + // backend twins to bind the replacement buffer name. (dev@d7655247, + // carried into this arm as well: the handle arm duplicates the + // immediate retire path, so a fix that lands in only one of the two + // leaves the bug alive on whichever arm the operator selects.) + ++g_bufferBackendIdGeneration; + g_GLESFuncs.glDeleteBuffers(1, &resource->id); + resource->id = 0; + resource->immutableStorage = false; + } + resource->storageInitialized = false; + resource->storageSize = 0; + resource->pendingRespecify = true; + resource->pendingRanges.clear(); + resource->pendingResidentWrites.clear(); + return; + } + if (!CanTouchGLNow() || resource->id == 0 || + resource->contextGeneration != g_bufferContextGeneration) { + resource->pendingRespecify = true; + resource->pendingRanges.clear(); + resource->pendingResidentWrites.clear(); + return; + } + if (desc.Width == 0) { + resource->storageInitialized = false; + resource->storageSize = 0; + resource->pendingRespecify = false; + resource->pendingRanges.clear(); + resource->pendingResidentWrites.clear(); + return; + } + // Same orphaning rule, expressed on the descriptor: a NULL-data respecify has + // HasDefinedContent clear and the client sends no bytes for it. + const void* initialData = desc.HasDefinedContent != 0 ? initialBytes : nullptr; + RespecifyStorageWith(*resource, static_cast(desc.Width), + MG_Util::ConvertBufferUsageToGLEnum(static_cast(desc.Usage)), + initialData, ResourceSerialOf(res)); + } + + void Ops_H_SubData(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPSubData& record, const void* bytes) { + const SizeT offset = static_cast(MG_Pipe::MGPipeSubDataBufferOffset(record)); + const SizeT size = static_cast(MG_Pipe::MGPipeSubDataBufferSize(record)); + auto* resource = FindBufferResourceForHandle(res); +#if MOBILEGL_BUILD_DISAGGREGATED + // R-11 / ID-52 item 3: under an ACTIVE TRANSPORT this record's bytes are the ONLY + // delivery of the buffer's content - the client sends no companion pointer and the + // server has no MappedData to fall back on. But the twin is created LAZILY at the + // first draw (Ops_H_Create is a no-op by D-A2), which is AFTER this record, so a + // subdata that finds no twin would return here and the bytes would be lost - the + // draw then uploads an empty/shifted store (TriangleScenario read a blue triangle + // before this). So under split the subdata MINTS the twin it needs to stage into. + // Monolith is untouched: the twin stays lazy there because the frontend object's + // MappedData is the source and nothing is lost by deferring the allocation (D-A2). + if (resource == nullptr && bytes != nullptr && + MG_Config::Transport != MG_Config::TransportMode::Monolith) { + resource = GetOrCreateBufferResourceForHandle(res); + } +#endif + if (!resource) return; + // M-2: UNDER pendingMutex, because this line runs BEFORE the CanTouchGLNow() + // test below - i.e. on the arm D-A2 deliberately keeps reachable off the render + // thread - while every reader of hostBytes (Ops_H_Readback's drain, the + // kill-switch map arm of Ops_H_FlushRange, the ensure path, the fp64 narrowing) + // is on the render thread. It was the one member of this struct that the + // off-thread path touched with neither a lock nor an atomic: pendingRanges and + // pendingResidentWrites are under this mutex and syncedChangeSerial is an + // std::atomic read with acquire. The lock it is put under is deliberately THAT + // one and not a new one - the base and the queued range are one fact ("these + // bytes, at this base"), the drain takes this mutex to lift the ranges, and a + // drain that sees a range therefore sees the base that range was queued + // against. The store is kept on both arms rather than restricted to the + // CanTouchGLNow() one because Ops_H_Readback can drain with no draw in between, + // and the ensure path's republication (the 3c55e027 fix) is what runs at a draw. + if (bytes != nullptr) { + const std::lock_guard lock(resource->pendingMutex); + // R-11: MONOLITH keeps the client's base; SPLIT copies [offset, offset+size) + // into server-owned storage and points hostBytes at that. Inside the SAME + // lock as the queued range, because the base and the range are one fact + // ("these bytes, at this base") and the drain takes this mutex to lift them. + resource->hostBytes = + MGL_SERVER_STAGED_ADOPT(*resource, ResourceWidthOf(res), bytes, offset, size); + } + if (resource->pendingRespecify) return; // full re-upload pending anyway + if (!CanTouchGLNow() || resource->id == 0 || + resource->contextGeneration != g_bufferContextGeneration || + !StorageMatchesSize(*resource, ResourceWidthOf(res))) { + const std::lock_guard lock(resource->pendingMutex); + resource->pendingRanges.Add({offset, offset + size}); + return; + } + // The adopted zero-copy store already HAS the bytes; a driver upload here would + // re-synchronize what coherent mapping made free. + if (resource->persistentMapped && resource->persistentPtr) { + resource->syncedChangeSerial = ResourceSerialOf(res); + return; + } + if (MG_Config::Features.EsprytDisableUploadRing) { + UploadRangeFrom(*resource, resource->hostBytes, offset, offset + size); + resource->syncedChangeSerial = ResourceSerialOf(res); + return; + } + // The Mali WAR-stall fix, unchanged: queue and let draw-time sync stage the + // merged ranges through the upload ring. + const std::lock_guard lock(resource->pendingMutex); + resource->pendingRanges.Add({offset, offset + size}); + } + + void Ops_H_ResidentSubData(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPSubData& record, + const void* bytes) { + const SizeT offset = static_cast(MG_Pipe::MGPipeSubDataBufferOffset(record)); + const SizeT size = static_cast(MG_Pipe::MGPipeSubDataBufferSize(record)); + auto* resource = FindBufferResourceForHandle(res); + if (!resource || size == 0 || bytes == nullptr) return; + // DELIBERATELY not stored as hostBytes: these are the application's staging + // store and are valid for the duration of the call only (BufferObject.h:84-85), + // which is exactly why they are COPIED here rather than referenced later. + const std::lock_guard lock(resource->pendingMutex); + auto& write = resource->pendingResidentWrites.emplace_back(); + write.offset = offset; + const auto* source = static_cast(bytes); + write.bytes.assign(source, source + size); + } + + void Ops_H_FlushRange(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPFlushRange& record, + const void* bytes) { + auto* resource = FindBufferResourceForHandle(res); + if (!resource) return; + const SizeT start = static_cast(record.Offset); + const SizeT end = start + static_cast(record.Size); + // M-2, the second store site - same lock, same reason as Ops_H_SubData's, and + // the same R-11 copy. UNDER SPLIT `bytes` IS ALWAYS NULL HERE by ruling (C-6 / + // contract table 1 row 20: resource_flush_range carries no bytes at all - the + // ladder it drives rewrites its range from the authoritative shadow, which rule + // C makes server-owned, so resource_subdata is already the only way bytes reach + // it and a second carrier would be a forgeable way to say the same thing). So + // this arm keeps whatever the preceding subdata records staged, which is + // exactly what the ladder must read. + if (bytes != nullptr) { + const std::lock_guard lock(resource->pendingMutex); + resource->hostBytes = MGL_SERVER_STAGED_ADOPT(*resource, ResourceWidthOf(res), bytes, + start, end - start); + } + if (resource->pendingRespecify) return; + if (!CanTouchGLNow() || resource->id == 0 || + resource->contextGeneration != g_bufferContextGeneration || + !StorageMatchesSize(*resource, ResourceWidthOf(res))) { + const std::lock_guard lock(resource->pendingMutex); + resource->pendingRanges.Add({start, end}); + return; + } + if (resource->persistentMapped && resource->persistentPtr) { + resource->syncedChangeSerial = ResourceSerialOf(res); + return; + } + if (!MG_Config::Features.EsprytDisableUploadRing) { + const std::lock_guard lock(resource->pendingMutex); + resource->pendingRanges.Add({start, end}); + return; + } + // The kill-switch arm, and it reads the application's REAL flags per call - + // which is why MGPFlushRange carries them unnormalised. + const Flags appAccess{ + static_cast>(record.AccessFlags)}; + const Bool invalidate = (appAccess & BufferMappingAccessBit::InvalidateRange) || + (appAccess & BufferMappingAccessBit::InvalidateBuffer); + const Bool unsynchronized = static_cast(appAccess & BufferMappingAccessBit::Unsynchronized); + if (PREFER_MAP_BUFFER_RANGE_FOR_BUFFER_SYNC && (invalidate || unsynchronized) && + resource->hostBytes != nullptr) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // The kill-switch arm's own Memcpy, and the one that passes + // GL_MAP_INVALIDATE_RANGE_BIT - i.e. the one that tells the driver the old + // bytes are dead. Bytes outside the staged coverage are exactly the ones + // that claim is false for. + MGL_SERVER_STAGED_REQUIRE(*resource, resource->hostBytes, start, end, + "flush_range_invalidate_map"); +#endif + BindBufferId(TempBufferTarget, resource->id); + void* mappedData = g_GLESFuncs.glMapBufferRange( + TempBufferTarget, (GLintptr)start, (GLsizeiptr)(end - start), + GL_MAP_WRITE_BIT | (invalidate ? GL_MAP_INVALIDATE_RANGE_BIT : 0) | + (unsynchronized ? GL_MAP_UNSYNCHRONIZED_BIT : 0)); + if (mappedData) { + Memcpy(mappedData, resource->hostBytes + start, end - start); + g_GLESFuncs.glUnmapBuffer(TempBufferTarget); + resource->syncedChangeSerial = ResourceSerialOf(res); + return; + } + MGLOG_E_ONCE("Failed to map buffer with ID: %u for flush, falling back to glBufferSubData", + resource->id); + } + UploadRangeFrom(*resource, resource->hostBytes, start, end); + resource->syncedChangeSerial = ResourceSerialOf(res); + } + + void Ops_H_Readback(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPReadback& record) { + auto* resource = FindBufferResourceForHandle(res); + if (!resource || resource->id == 0 || !resource->storageInitialized) return; + if (!CanTouchGLNow() || resource->contextGeneration != g_bufferContextGeneration) return; + if (resource->persistentMapped) { + // Queued resident SubData bytes land first (GPU-ordered), then the finish + // makes them - and any shader writes already queued on this context - + // visible through the coherent mapping the reads use. There is no backend + // copy to read back in this case. + DrainResidentWritesNow(*resource); + if (g_GLESFuncs.glFinish) g_GLESFuncs.glFinish(); + return; + } + if (!g_GLESFuncs.glMapBufferRange || !g_GLESFuncs.glUnmapBuffer) return; + // The map below starts at record.Offset where the legacy arm mapped at literal + // zero, so the clamp has to be against what is left of the BACKEND store past + // that offset: the applier's BufferRangeFault checked the range against + // Desc.Width, and a store that is smaller than the descriptor (a respecify the + // backend has not applied yet) would otherwise be mapped past its end. + const SizeT readOffset = static_cast(record.Offset); + if (readOffset >= resource->storageSize) return; + const SizeT size = + std::min(static_cast(record.Size), resource->storageSize - readOffset); + if (size == 0) return; + + // Queued app writes must land in the backend store before it is read back, or + // the writeback below would revert them in the shadow. Under split every queued + // range must lie inside the server shadow's staged coverage first (R-11 / the + // M-6 ruling): the drain's tier-1 map would otherwise declare live GPU bytes + // dead over a range nothing staged. + MGL_SERVER_STAGED_REQUIRE_PENDING(*resource, resource->hostBytes, ResourceWidthOf(res), + "readback_flush_pending"); + FlushPendingRangesFrom(*resource, resource->hostBytes, ResourceWidthOf(res)); + + BindBufferId(TempBufferTarget, resource->id); + void* mapped = g_GLESFuncs.glMapBufferRange(TempBufferTarget, (GLintptr)record.Offset, + static_cast(size), GL_MAP_READ_BIT); + if (mapped == nullptr) { + MGLOG_E_ONCE("Ops_H_Readback: glMapBufferRange(read) failed for buffer %u", resource->id); + return; + } + // The reverse channel replaces BufferObject::WritebackFromBackend: the backend + // no longer reaches into the frontend's address space, it ANSWERS. In monolith + // the client's implementation is one call away, so SyncGpuWrites' caller still + // sees the reconciled shadow on return, exactly as before. + // + // THE ORDER FROM HERE IS A CORRECTNESS RULE, NOT A PREFERENCE + // (ARCHITECTURE.md 8.2: "写回的 epoch bump 必须在任何后续读该 handle 的命令之前被 + // server 应用;反向通道需要与正向通道相同的有序保证"): + // 1. writeback - the client's shadow takes the bytes, + // 2. unmap - the read mapping goes away, + // 3. serial stamp- syncedChangeSerial catches up with the record, so the next + // draw does not re-upload the readback over itself, + // 4. epoch bump - in Ops_H_ReadbackTracked, i.e. strictly AFTER 1-3, so a + // draw-clean memo re-probed by the bump can never observe a + // half-reconciled resource. The bump must NEVER move earlier. + if (MG_Pipe::gMGPipeCallbacks.OnBufferWriteback != nullptr) { + MG_Pipe::gMGPipeCallbacks.OnBufferWriteback( + res, record.Offset, + MG_Pipe::MGPBlobRef{reinterpret_cast(mapped), static_cast(size), + MG_Pipe::kMGHostSpanSegNone, 0}); + } else { + MGLOG_E_ONCE("Ops_H_Readback: no reverse channel is installed, so the GPU-written bytes of " + "buffer %u cannot reach the client shadow", + resource->id); + } + g_GLESFuncs.glUnmapBuffer(TempBufferTarget); + // The shadow now matches the backend byte for byte; without this the next draw + // would see a newer serial and re-upload the readback over it. + resource->syncedChangeSerial = ResourceSerialOf(res); + } + + void Ops_H_Destroy(MG_Pipe::MGPipeHandle res) { + // R-11's server copy dies with the resource, and BEFORE the twin leaves the + // table - the side map is keyed by the twin's address, so this is the last + // moment that address can be looked up. + if (GLESBufferResource* dying = FindBufferResourceForHandle(res); dying != nullptr) { + MGL_SERVER_STAGED_DROP(*dying); + } + // The twin comes OUT of the table first, so the three outcomes below are + // reached with the entry already retired. The SLOT is the client's to free, + // after this returns (D-L). + SharedPtr twin = g_backendBufferResources.ReleaseByHandle(res); + // Verbatim Ops_OnDestroy: stale generation -> zero the id; on-thread -> + // IsPoolable/EnrollIntoPool else scrub + glDeleteBuffers; off-thread -> + // g_deferredBufferReleases plus the lock-free flag. + Ops_OnDestroy(std::move(twin)); + } + + // D-E: the ONLY two changes are the signature and the three reads that went + // through the frontend object (GetSize() -> size, MappedData() -> seedBytes, + // SetBackendResource(...) -> the slot table's GetOrCreate). Every other statement + // is byte-identical to Ops_AcquirePersistentMap above - the four-way capability + // gate, the stale-generation wipe BEFORE the new stamp, the idempotency hit, the + // fresh-id sequence with ++g_bufferBackendIdGeneration, immutableStorage set as + // soon as the store exists, the MGLOG_E_ONCE decline and the success stamps. + void* Ops_H_MapPersistent(MG_Pipe::MGPipeHandle res, Uint64 size, const void* seedBytes) { + if (!CanTouchGLNow() || !g_GLESFuncs.glBufferStorageEXT || !g_GLESFuncs.glMapBufferRange || + !g_GLESFuncs.glGenBuffers) { + return nullptr; + } + if (size == 0) return nullptr; + + auto* resource = GetOrCreateBufferResourceForHandle(res); + if (!resource) return nullptr; + // Before the generation is stamped, not after: everything on the resource + // describes a context that is gone, and the idempotency check below would + // otherwise hand the caller the dead context's mapped pointer. + if (resource->contextGeneration != g_bufferContextGeneration) { + resource->id = 0; + resource->persistentMapped = false; + resource->persistentPtr = nullptr; + resource->immutableStorage = false; + resource->storageInitialized = false; + resource->storageSize = 0; + } + resource->contextGeneration = g_bufferContextGeneration; + + if (resource->persistentMapped && resource->persistentPtr && resource->storageSize == size) { + return resource->persistentPtr; // idempotent + } + + // Need a fresh id: glBufferStorage fails on a buffer that already has + // immutable storage, and any prior mutable store is replaced anyway. + if (resource->id != 0) { + NoteBufferIdDeleted(resource->id); + // Driver VAOs may have this id baked into attribute/element bindings + // keyed on versions this re-mint does not move. + ++g_bufferBackendIdGeneration; + g_GLESFuncs.glDeleteBuffers(1, &resource->id); + resource->id = 0; + resource->immutableStorage = false; + } + g_GLESFuncs.glGenBuffers(1, &resource->id); + if (resource->id == 0) return nullptr; + + // Seed from the shadow (the client's bytes are still live at this point: it + // adopts and drops them only after this returns). + BindBufferId(TempBufferTarget, resource->id); + const void* initial = seedBytes; + g_GLESFuncs.glBufferStorageEXT(TempBufferTarget, static_cast(size), initial, + GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit | + kDynamicStorageBit); + // Set as soon as the store exists, not once the map succeeds: the failure + // path below leaves this id holding immutable storage, and whoever touches + // it next has to know that glBufferData cannot redefine it. + resource->immutableStorage = true; + void* ptr = g_GLESFuncs.glMapBufferRange(TempBufferTarget, 0, static_cast(size), + GL_MAP_WRITE_BIT | kMapPersistentBit | kMapCoherentBit); + if (!ptr) { + MGLOG_E_ONCE("Ops_H_MapPersistent: glMapBufferRange(persistent) failed for buffer %u", + resource->id); + resource->persistentMapped = false; + resource->persistentPtr = nullptr; + return nullptr; + } + resource->persistentPtr = ptr; + resource->persistentMapped = true; + // The ONE statement here that is not in Ops_AcquirePersistentMap, and it is + // about a member the legacy arm does not have rather than about the map: the + // client adopts this pointer the instant we return it and + // PipeResource::AdoptPersistentMap then does m_shadow->clear() + + // shrink_to_fit(), so the shadow base any earlier content-carrying call + // recorded is a FREED allocation from here on. The live bytes are persistentPtr. + // R-11's server copy goes with it: the bytes are the coherent map's now, and a + // stale server shadow would answer a later drain with pre-map content. + MGL_SERVER_STAGED_DROP(*resource); + resource->hostBytes = nullptr; + resource->storageSize = static_cast(size); + resource->storageInitialized = true; + resource->pendingRespecify = false; + { + const std::lock_guard lock(resource->pendingMutex); + resource->pendingRanges.clear(); + resource->pendingResidentWrites.clear(); + } + resource->syncedChangeSerial = ResourceSerialOf(res); + return ptr; + } + + void Ops_H_UnmapPersistent(MG_Pipe::MGPipeHandle res) { + (void)res; + // P3a emits this from nowhere: the donation is permanent for the life of the + // store and is ended by the respecify / destroy paths, which already retire + // the immutable id. The entry exists so the transport has both halves of the + // pair (D-E), and giving it a body that unmapped a live coherent store would + // be the one thing D-B4 forbids. + } + + // The epoch-tracking wrappers, duplicated for this table - same contract as the + // seven above, including AcquirePersistentMap's bump EVEN ON DECLINE (the client + // still enters a persistent map the per-draw probes must start seeing). + void Ops_H_RespecifyTracked(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPResourceDesc& desc, + const void* initialBytes) { + Ops_H_Respecify(res, desc, initialBytes); + BumpBufferMutationEpoch(); + } + void Ops_H_SubDataTracked(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPSubData& record, + const void* bytes) { + Ops_H_SubData(res, record, bytes); BumpBufferMutationEpoch(); } - void Ops_ResidentSubDataTracked(BufferObject& bufferObject, SizeT offset, DataPtr data) { - Ops_ResidentSubData(bufferObject, offset, data); + void Ops_H_ResidentSubDataTracked(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPSubData& record, + const void* bytes) { + Ops_H_ResidentSubData(res, record, bytes); BumpBufferMutationEpoch(); } - void Ops_FlushMappedRangeTracked(BufferObject& bufferObject, Range1D range, - Flags appAccess) { - Ops_FlushMappedRange(bufferObject, range, appAccess); + void Ops_H_FlushRangeTracked(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPFlushRange& record, + const void* bytes) { + Ops_H_FlushRange(res, record, bytes); BumpBufferMutationEpoch(); } - void Ops_OnDestroyTracked(SharedPtr&& resource) { - Ops_OnDestroy(std::move(resource)); + void Ops_H_ReadbackTracked(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPReadback& record) { + Ops_H_Readback(res, record); BumpBufferMutationEpoch(); } - void* Ops_AcquirePersistentMapTracked(BufferObject& bufferObject) { - void* result = Ops_AcquirePersistentMap(bufferObject); - // Bump even on decline: the frontend still enters a persistent map the - // per-draw probes must start seeing (IsMapped-driven range pushes). + void Ops_H_DestroyTracked(MG_Pipe::MGPipeHandle res) { + Ops_H_Destroy(res); BumpBufferMutationEpoch(); - return result; } - void Ops_ReadbackFromGpuTracked(BufferObject& bufferObject) { - Ops_ReadbackFromGpu(bufferObject); + void* Ops_H_MapPersistentTracked(MG_Pipe::MGPipeHandle res, Uint64 size, const void* seedBytes) { + void* result = Ops_H_MapPersistent(res, size, seedBytes); + // Bump even on decline: the client still enters a persistent map the per-draw + // probes must start seeing. BumpBufferMutationEpoch(); + return result; + } + +#if MOBILEGL_BUILD_DISAGGREGATED + // ---- P5c (tx): the TEXTURE half of the resource family, staged server-side -------- + // + // The buffer half's R-11 pattern (ServerStaged() above), for texture levels: the + // staged bytes of resource_subdata's texture half are adopted into the server's + // StagedTextureStore AT APPLY TIME, because `bytes` names SEG_STAGE and is dead the + // moment the record retires (rule C). The store, its ownership and its coverage + // rules are documented in MG_Remote/Server/StagedTextureStore.h; these are only the + // three hook bodies. All three are no-ops in monolith (CopiesIntoServerStorage() + // false), which is what keeps the monolith expression character for character. + // + // NOTHING here mints or reads a texture twin: the store is keyed by the wire handle + // the record carried (StagedTextureStore.h explains why not the twin address), so + // adoption needs no GL call and no frontend object. + + // GetUploadTargets() answered from the descriptor's Target: one static list per + // target, matching the frontend classes' own lists member for member + // (TextureObject*.h / TextureObjectStubs.h: a cube is six faces, a cube array is + // the single CubeMapArray target, everything else its own one). + const Vector& StagedUploadTargetsForPipeTarget(Uint8 pipeResourceTarget) { + static const Vector kUnknown{}; + static const Vector kTex1D{TextureUploadTarget::Texture1D}; + static const Vector kTex2D{TextureUploadTarget::Texture2D}; + static const Vector kTex3D{TextureUploadTarget::Texture3D}; + static const Vector kTex1DArray{TextureUploadTarget::Texture1DArray}; + static const Vector kTex2DArray{TextureUploadTarget::Texture2DArray}; + static const Vector kTexCube{ + TextureUploadTarget::CubeMapPositiveX, TextureUploadTarget::CubeMapNegativeX, + TextureUploadTarget::CubeMapPositiveY, TextureUploadTarget::CubeMapNegativeY, + TextureUploadTarget::CubeMapPositiveZ, TextureUploadTarget::CubeMapNegativeZ}; + static const Vector kTexCubeArray{TextureUploadTarget::CubeMapArray}; + static const Vector kTex2DMS{TextureUploadTarget::Texture2DMultisample}; + static const Vector kTex2DMSArray{TextureUploadTarget::Texture2DMultisampleArray}; + static const Vector kTexRect{TextureUploadTarget::TextureRectangle}; + static const Vector kTexBuffer{TextureUploadTarget::TextureBuffer}; + switch (static_cast(pipeResourceTarget)) { + case MG_Pipe::MGPipeResourceTarget::Tex1D: return kTex1D; + case MG_Pipe::MGPipeResourceTarget::Tex2D: return kTex2D; + case MG_Pipe::MGPipeResourceTarget::Tex3D: return kTex3D; + case MG_Pipe::MGPipeResourceTarget::Tex1DArray: return kTex1DArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DArray: return kTex2DArray; + case MG_Pipe::MGPipeResourceTarget::TexCube: return kTexCube; + case MG_Pipe::MGPipeResourceTarget::TexCubeArray: return kTexCubeArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DMS: return kTex2DMS; + case MG_Pipe::MGPipeResourceTarget::Tex2DMSArray: return kTex2DMSArray; + case MG_Pipe::MGPipeResourceTarget::TexRect: return kTexRect; + case MG_Pipe::MGPipeResourceTarget::TexBuffer: return kTexBuffer; + default: return kUnknown; + } + } + +// P5e (ID-124): OUT OF THE DISAGGREGATED BLOCK ON PURPOSE. This is a pure +// MGPipeResourceTarget -> TextureTarget switch with no wire, no record and no role in it, but +// it sat inside the nested `#if MOBILEGL_BUILD_DISAGGREGATED` below while its only caller, +// MGB_TEXPARAM_TARGET, is guarded by `#if MOBILEGL_PIPE_PUSH` alone - so a push build without +// disaggregation did not compile. The narrower fix (guarding the macro block too) is wrong: +// that block also carries TextureDiagName and MGB_TEXTURE_RECORD_ARM_SELECTED, which the push +// flavour does need. Caught by the `flavours` gate step; see ID-124. +#endif // MOBILEGL_BUILD_DISAGGREGATED + // The inverse of MG_Pipe::MGPipeResourceTargetForTextureTarget, for the sync's + // target reads (ConvertTextureTargetToBackendGLEnum and MapToBackendTextureTarget + // both want the frontend enum). + TextureTarget StagedTextureTargetForPipeTarget(Uint8 pipeResourceTarget) { + switch (static_cast(pipeResourceTarget)) { + case MG_Pipe::MGPipeResourceTarget::Tex1D: return TextureTarget::Texture1D; + case MG_Pipe::MGPipeResourceTarget::Tex2D: return TextureTarget::Texture2D; + case MG_Pipe::MGPipeResourceTarget::Tex3D: return TextureTarget::Texture3D; + case MG_Pipe::MGPipeResourceTarget::Tex1DArray: return TextureTarget::Texture1DArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DArray: return TextureTarget::Texture2DArray; + case MG_Pipe::MGPipeResourceTarget::TexCube: return TextureTarget::TextureCubeMap; + case MG_Pipe::MGPipeResourceTarget::TexCubeArray: return TextureTarget::TextureCubeMapArray; + case MG_Pipe::MGPipeResourceTarget::Tex2DMS: return TextureTarget::Texture2DMultisample; + case MG_Pipe::MGPipeResourceTarget::Tex2DMSArray: return TextureTarget::Texture2DMultisampleArray; + case MG_Pipe::MGPipeResourceTarget::TexRect: return TextureTarget::TextureRectangle; + case MG_Pipe::MGPipeResourceTarget::TexBuffer: return TextureTarget::TextureBuffer; + default: return TextureTarget::Unknown; + } + } +#if MOBILEGL_BUILD_DISAGGREGATED + + void Ops_H_TextureSubData(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPSubData& record, + const void* bytes, const MG_Pipe::MGPSubRegion* regions) { + // The region set is the upload planner's shape and stays in the applier's + // pending set; the store's coverage is the staged run itself + // (StagedTextureStore.h's coverage ruling). + (void)regions; + auto& store = MG_Remote::Server::ServerStagedTexture(); + if (!store.CopiesIntoServerStorage()) return; + // The applier's gate has already faulted every shape that reaches here without + // bytes, and under split the codec declared the run's length (Blob.Size) - + // TextureEmit.h:1285's "the bytes this record declares ARE the level shadow". + if (bytes == nullptr || record.Blob.Size == 0) return; + const auto* stored = PipeTextureRecordForHandle(res); + if (stored == nullptr) return; + const IntVec3 extent = MG_Remote::Server::StagedTextureMipExtent( + stored->Desc.Target, stored->Desc.Width, stored->Desc.Height, stored->Desc.Depth, + static_cast(record.Level)); + store.Adopt(MG_Remote::Server::StagedTextureStore::KeyForHandle(res), + MG_Pipe::MGPipeSubDataUploadTargetOf(record.Target), record.Level, extent, + bytes, static_cast(record.Blob.Size)); + } + + void Ops_H_TextureRespecify(MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPResourceDesc& desc, + const MG_Pipe::MGPRespecifiedLevel* level) { + auto& store = MG_Remote::Server::ServerStagedTexture(); + if (!store.CopiesIntoServerStorage()) return; + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForHandle(res); + if (level != nullptr) { + // ONE glTexImage*D redefined one level: it exists from here on, at the + // derived extent (§1). NoteLevelDefined keeps a same-extent level's bytes. + store.NoteLevelDefined( + key, MG_Pipe::MGPipeSubDataUploadTargetOf(level->UploadTarget), level->Level, + MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, level->Level)); + return; + } + // A whole-resource redefinition: every level's old coordinate system is gone. + // glTexStorage* then defines the WHOLE chain at once (GL 4.6 core 8.19 - all six + // cube faces included), so an immutable descriptor re-marks every level of every + // upload target; a mutable whole-resource respecify (a texture view) defines + // nothing here and the store simply forgets the old levels. + store.ResetLevels(key); + if (desc.Immutable == 0 || desc.Levels == 0) return; + for (const auto& uploadTarget : StagedUploadTargetsForPipeTarget(desc.Target)) { + for (Uint32 levelIndex = 0; levelIndex < desc.Levels; ++levelIndex) { + store.NoteLevelDefined( + key, static_cast(uploadTarget), static_cast(levelIndex), + MG_Remote::Server::StagedTextureMipExtent(desc.Target, desc.Width, desc.Height, + desc.Depth, levelIndex)); + } + } } + void Ops_H_TextureDestroy(MG_Pipe::MGPipeHandle res) { + // Deliberately NOT gated on CopiesIntoServerStorage(): Drop's own m_any gate + // makes the monolith call one acquire load, and an unconditional drop cannot + // strand a key the latch state was misread for. + MG_Remote::Server::ServerStagedTexture().Drop( + MG_Remote::Server::StagedTextureStore::KeyForHandle(res)); + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + + const MG_Pipe::MGPipeResourceOps g_glesResourceOps = { + .Create = Ops_H_Create, + .Respecify = Ops_H_RespecifyTracked, + .SubData = Ops_H_SubDataTracked, + .SubDataResident = Ops_H_ResidentSubDataTracked, + .FlushRange = Ops_H_FlushRangeTracked, + .Readback = Ops_H_ReadbackTracked, + .Destroy = Ops_H_DestroyTracked, + .MapPersistent = Ops_H_MapPersistentTracked, + .UnmapPersistent = Ops_H_UnmapPersistent, +#if MOBILEGL_BUILD_DISAGGREGATED + .TextureSubData = Ops_H_TextureSubData, + .TextureRespecify = Ops_H_TextureRespecify, + .TextureDestroy = Ops_H_TextureDestroy, +#endif + }; +#endif // MOBILEGL_PIPE_PUSH + const BufferBackendOps g_glesBufferBackendOps = { .Respecify = Ops_RespecifyTracked, .SubData = Ops_SubDataTracked, @@ -1348,16 +2795,329 @@ namespace MobileGL::MG_Backend::DirectGLES { return g_bufferMutationEpoch.load(std::memory_order_acquire); } +#if MOBILEGL_BUILD_DISAGGREGATED + namespace { + // P5e (vi): how many times BumpBufferMutationEpoch ran off the apply thread while + // one was running, with a live transport. See the note in the bump itself; the + // accessor is what a test can assert zero on instead of reading a call graph. + std::atomic g_bufferMutationEpochBumpsOffTheApplyThread{0}; + } // namespace + + Uint64 BufferMutationEpochBumpsOffTheApplyThread() { + return g_bufferMutationEpochBumpsOffTheApplyThread.load(std::memory_order_relaxed); + } +#endif + void BumpBufferMutationEpoch() { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (vi), CONTRACT-P5E §5.1's first pin (scout S1 R2, BRIEF-P5E's open question 2): + // "does any GL-THREAD path bump this under a transport". PINNED HERE RATHER THAN + // ARGUED, because the argument is the kind that is true until someone adds a caller. + // + // It matters because this counter is the ONE non-serial input left in the record + // arm's clean gate: SyncVaoAttributeBuffersByRecord stamps the pre-pass value and + // skips every IsBufferDrawCleanByHandle probe while the stamp holds. A bump from the + // GL thread would make that stamp a cross-thread read of a value the applier never + // observed - the memo would go on reading clean over buffers the client had just + // dirtied - and under run-ahead the client is a frame ahead, so the window is a + // frame wide rather than a record wide. + // + // THE MEASURED ANSWER, and it is not the clean "no" the scout expected: there is + // EXACTLY ONE class of non-apply-thread bump under a transport, and it happens + // BEFORE THERE IS AN APPLY THREAD AT ALL. BackendObject_DirectGLES::Initialize() + // runs on the app thread by design ("the backend object is BUILT on the app thread + // while its context is never OWNED by it", ServerLoop.h) and calls + // RegisterBufferBackendOps(), whose last act is a bump. An unguarded refusal aborts + // the whole split lane in EGL bring-up, which is how this was found rather than + // reasoned. + // + // That class is harmless and the apply-thread key tells it apart from the one that + // would not be: with g_applyThreadKey == 0 no record has been applied, so no twin + // exists, so no memo holds a stamp for the bump to slide under. + // + // WHAT IS COUNTED AND WHAT IS FATAL, and why they are not the same thing. Every + // bump taken off the apply thread WHILE ONE IS RUNNING is counted and named once, + // and is Fatal UNDER THE STRICT LANE - which is where this phase's pins live, and + // which is the lane the answer is claimed for. It is not Fatal by default because + // the unit fixtures that drive the production op tables directly + // (StagedShadowProductionTest / ServerLoopTest, which register the real tables and + // call them from the test thread beside a started loop) are the server role without + // being the apply thread, and taking those down would be this pin policing a + // fixture rather than the draw path. + const Uint64 applyThreadKey = + MG_Remote::Server::Detail::g_applyThreadKey.load(std::memory_order_relaxed); + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && applyThreadKey != 0 && + !MG_Remote::Server::ServerLoop::OnApplyThread()) { + g_bufferMutationEpochBumpsOffTheApplyThread.fetch_add(1, std::memory_order_relaxed); + if (MG_Config::Ipc.StrictErrors) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"CurrentBufferMutationEpoch\"} - the " + "buffer-mutation epoch was bumped off the apply thread while an apply " + "thread was running, with a live transport. The draw-path memos stamp " + "this counter and skip their clean probes while the stamp holds " + "(CONTRACT-P5E §5.1), so a producer on the client's thread means the " + "backend is reachable from a thread that owns none of this state"); + std::abort(); + } + MGLOG_E_ONCE("MGPipe: the buffer-mutation epoch was bumped off the apply thread " + "with a live transport (CONTRACT-P5E §5.1's pin). Run the strict lane " + "to make this fatal at the producer"); + } +#endif g_bufferMutationEpoch.fetch_add(1, std::memory_order_release); } +#if MOBILEGL_PIPE_PUSH + // The seventh slot table. A process-lifetime global, like the other six, so it links + // itself into its own holder list from its own constructor with no initialisation + // order question to answer (SlotTables.h). + BackendBufferResourceTable g_backendBufferResources; + + // The two P3a families get the three-way treatment ClassifyEsprytSlotArm established for + // bit 5, rather than reading their bit and stopping there: "the bit is clear AND the + // legacy arm has been taken away" is a real configuration an operator can ask for, and a + // family that only reads its own bit answers it by running the very arm that was + // disabled, in silence. The two families differ in whether that third verdict is + // REACHABLE, which is why the classifier takes it as a parameter rather than assuming it. + // + // A STOP, exactly like ResolveEsprytSlotTablesArm's NoArm, and M-4 is the promotion. + // + // It was a warning while the premise for the warning held: bits 7 and 8 were clear in + // every `.Handles` itest lane, which pinned MOBILEGL_PIPE_PUSH=0x7f beside + // MOBILEGL_PIPE_LEGACY_MEMOS=0, so aborting would have turned that lane red for a + // mis-pinned control rather than for a defect. That premise is gone: gates' M1 re-pinned + // MGL_ITEST_HANDLES_ARM_KNOBS to "MOBILEGL_PIPE_LEGACY_MEMOS=0" + "MOBILEGL_PIPE_PUSH=0x1ff" + // (MG_IntegrationTest/CMakeLists.txt), and the four CSO lanes went to 0x1ff / + // 0x800000000000_01ff, which is contract-review item 11 closed for all three lane + // families. The only lanes that set LEGACY_MEMOS=0 now carry an explicit vertex-input + // arm, so an armless verdict can no longer be a lane's own configuration - it can only + // be an operator who asked for both arms to be gone. + // + // Warning-and-continue is the wrong answer to that, for ResolveEsprytSlotTablesArm's + // reason verbatim: continuing runs the very arm the operator disabled and hands back a + // result measured on it, which is exactly the lever HandleRecycleScenario's arms are + // selected with, so a mis-set A/B would be scored silently against the wrong arm + // (ARCHITECTURE.md 9.6). Resolved lazily at the first use, not at bring-up, so the stop + // lands in a scenario body where ctest reports it rather than inside eglMakeCurrent + // where the harness reads an abort as "no usable GPU" and skips. + // + // This closes espryt D14 / closure row 7 and, with it, row 6's "LEGACY_MEMOS=0 + + // PUSH=0x7f must name a verdict": that combination now names NoArm and stops. + enum class PipeSubsystemArmVerdict { Handles, Legacy, NoArm }; + + // The one voice for an armless verdict, so the two families cannot disagree about what + // it means or about whether it stops. `what` names the family and the bit. + [[noreturn]] void StopOnArmlessPipeSubsystem(const char* what) { + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled, \"%s, so there is no arm to run\"}", what); + std::abort(); + } + + PipeSubsystemArmVerdict ClassifyPipeSubsystemArm(Bool subsystemBitSet, Bool legacyMemosEnabled, + Bool legacyArmSurvivesLegacyMemos) { + if (subsystemBitSet) return PipeSubsystemArmVerdict::Handles; + if (legacyArmSurvivesLegacyMemos || legacyMemosEnabled) return PipeSubsystemArmVerdict::Legacy; + return PipeSubsystemArmVerdict::NoArm; + } + + Bool ResolveResourceSubsystemArm() { + const Bool bitSet = (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemResources) != 0; + // The buffer family's legacy arm is the Ops_* table and g_glesBufferBackendOps, which + // are compiled UNCONDITIONALLY - only the VAO twin's memos and the pre-handle VAO + // body live under MOBILEGL_PIPE_LEGACY_MEMOS - so this family cannot be left armless + // and NoArm is unreachable for it. Said out loud rather than assumed, because the + // knob is still answered below. + const PipeSubsystemArmVerdict verdict = + ClassifyPipeSubsystemArm(bitSet, MG_Config::Features.PipeLegacyMemos, + /*legacyArmSurvivesLegacyMemos=*/true); + // Unreachable for this family by the argument above, and stated as a stop anyway: + // the two P3a families answer an armless verdict the same way, and an unreachable + // branch that says something different is how the reachable one drifts. + if (verdict == PipeSubsystemArmVerdict::NoArm) { + StopOnArmlessPipeSubsystem("MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemResources " + "(bit 7) clear and the pre-handle buffer arm is gone"); + } + if (verdict == PipeSubsystemArmVerdict::Legacy && !MG_Config::Features.PipeLegacyMemos) { + MGLOG_W("MGPipe: MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemResources (bit 7) clear while " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 asks for the pre-handle arms to be gone; the buffer " + "ops table is compiled unconditionally, so the LEGACY arm is what this process runs"); + } + const Bool enabled = verdict == PipeSubsystemArmVerdict::Handles; + MGLOG_D("MGPipe: Espryt resource family runs the %s arm", enabled ? "handle" : "legacy"); + return enabled; + } + + Bool ResolveVertexInputSubsystemArm() { + const Bool bitSet = (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemVertexInput) != 0; + const Bool resourcesBitSet = + (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemResources) != 0; + // BIT 8 REQUIRES BIT 7, and it is refused here rather than half-run. The vertex-input + // handle arm resolves every attribute's driver buffer id through + // FindBufferResourceForHandle, i.e. out of the resource SLOT TABLE - and only bit 7 + // puts twins in that table (with bit 7 clear EnsureBufferResource takes the legacy + // arm and parks the twin on PipeResource::m_backend instead). The pair therefore + // produced a walk in which every BindAttributeBufferByHandle logged once and + // `continue`d WITHOUT disabling the array, so every draw fetched through whatever + // pointer the driver VAO last held. The mirror pair (bit 7 set, bit 8 clear) is fine: + // the legacy VAO walk calls BindAttributeBuffer -> EnsureBufferResource, which + // dispatches to the handle arm by itself. + if (bitSet && !resourcesBitSet) { + MGLOG_E("MGPipe: kMGPipeSubsystemVertexInput (bit 8) is set but kMGPipeSubsystemResources " + "(bit 7) is clear; the vertex-input handle arm resolves attribute buffer ids " + "through the resource slot table, which only bit 7 populates - REFUSING bit 8 and " + "running the legacy vertex-input arm. Set bit 7 as well, or clear both"); + return false; + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + constexpr Bool kLegacyVaoArmCompiled = true; +#else + constexpr Bool kLegacyVaoArmCompiled = false; +#endif + // Unlike the buffer family, this one's legacy arm IS conditional: the pre-handle + // SyncToBackend body and the twin memos it reads are inside MOBILEGL_PIPE_LEGACY_MEMOS. + const PipeSubsystemArmVerdict verdict = + ClassifyPipeSubsystemArm(bitSet, MG_Config::Features.PipeLegacyMemos, + /*legacyArmSurvivesLegacyMemos=*/false); + if (verdict != PipeSubsystemArmVerdict::Handles && + (verdict == PipeSubsystemArmVerdict::NoArm || !kLegacyVaoArmCompiled)) { + // M-4: A STOP, not a log line. Continuing here left the process drawing through + // an UNCONFIGURED driver VAO - every attribute pointer whatever the last owner + // of that VAO name set it to - after one MGLOG_E that a lane summary does not + // read. The lanes that pin MOBILEGL_PIPE_LEGACY_MEMOS=0 all carry an explicit + // 0x1ff now, so this verdict is an operator's configuration and nothing else. + StopOnArmlessPipeSubsystem( + kLegacyVaoArmCompiled + ? "MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemVertexInput (bit 8) clear and " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 disables the pre-handle VAO sync" + : "MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemVertexInput (bit 8) clear and " + "the pre-handle VAO sync is not compiled into this build"); + } + const Bool enabled = verdict == PipeSubsystemArmVerdict::Handles; + MGLOG_D("MGPipe: Espryt vertex-input family runs the %s arm", enabled ? "handle" : "legacy"); + return enabled; + } + + GLESBufferResource* GetOrCreateBufferResourceForHandle(MG_Pipe::MGPipeHandle res) { + if (MG_Pipe::MGPipeHandleIsNull(res)) return nullptr; + // The table refuses both of these itself; this is the release-build VOICE for the + // refusal, because MOBILEGL_ASSERT compiles out at INFO and a resource that silently + // stops being twinned is the failure mode the refusal exists to replace. + if (res.Slot >= BackendBufferResourceTable::kMaxHandleSlot) { + MGLOG_E_ONCE("MGPipe: resource handle slot %u is past the backend table's %u bound - " + "refusing to twin it", + res.Slot, BackendBufferResourceTable::kMaxHandleSlot); + return nullptr; + } + const Uint32 liveGen = g_backendBufferResources.LiveGenAt(res.Slot); + if (liveGen != 0 && liveGen > res.Gen) { + MGLOG_E_ONCE("MGPipe: resource handle {%u, %u} names a generation BEHIND the live twin's " + "%u - refusing rather than dropping the incumbent's driver storage", + res.Slot, res.Gen, liveGen); + return nullptr; + } + auto& twin = g_backendBufferResources.GetOrCreate(res); + if (!twin) { + twin = MakeShared(); + // Same seed the lazy legacy path gives a resource it has just minted: nothing + // has defined storage yet, so the first sync owes a full (re)specification. + twin->pendingRespecify = true; + } + return twin.get(); + } + + GLESBufferResource* FindBufferResourceForHandle(MG_Pipe::MGPipeHandle res) { + auto* twin = g_backendBufferResources.FindByHandle(res); + return twin ? twin->get() : nullptr; + } + +#if MOBILEGL_BUILD_DISAGGREGATED + void RequireStagedCoverage(GLESBufferResource& resource, const Uint8* hostBase, SizeT start, + SizeT end, const char* site) { + ServerStaged().RequireCoverage(&resource, hostBase, start, end, site); + } +#endif + + MG_Pipe::MGPipeHandle HandleOfBuffer(const MG_State::GLState::BufferObject* bufferObject) { + if (bufferObject == nullptr) return MG_Pipe::kMGPipeNullHandle; + // Through the table's own single-entry front memo rather than straight into + // MGPipeSlots().FindByLifetimeId, which is a hash lookup: this runs inside + // EnsureBufferResource (every SSBO / UBO / atomic / XFB / PBO / IBO bind), inside + // IsBufferDrawClean, and once per SSBO per draw through MarkBufferGpuWritten, where + // the legacy arm paid a pointer compare. HandleOf answers identically - the same + // allocator probe on a miss - and remembers the answer, which the minting overload + // was previously the only writer of. A null answer is deliberately never memoised + // (see the comment at HandleOf). +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c merge coordination (CONTRACT-P5C §3.1's named exemption): every caller of + // this function is a site whose handle-carrying records (set_shader_buffers / + // set_stream_output_targets) the client does not emit until sb, so the probe + // below runs inside the scoped exemption. The scope, not a silent guard removal, + // is what keeps the debt named and greppable. + // + // P5e (id), ruling 12: THE SCOPE CHANGED UNDER THIS SITE AND THE SITE HAD TO MOVE. + // The exemption it used to name is now MagmaP7AllocatorDebtScope and is keyed on a + // DirectVulkan server (Magma's four P7 debts); this is Espryt's, and it belongs to + // the frontend-keyed registry family with the rest of the Espryt debt. Which scope + // it names is not cosmetic: the Magma one would not exempt it here at all. It + // retires when vi/sb carry the handle into EnsureBufferResourceForHandle / + // IsBufferDrawCleanByHandle / MarkBufferGpuWrittenByHandle, which is the last + // caller set this function has. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + return g_backendBufferResources.HandleOf(bufferObject); + } + + SizeT ResourceWidthForHandle(MG_Pipe::MGPipeHandle res) { return ResourceWidthOf(res); } + Uint64 ResourceSerialForHandle(MG_Pipe::MGPipeHandle res) { return ResourceSerialOf(res); } + Uint CurrentBufferContextGeneration() { return g_bufferContextGeneration; } + + void MarkBufferGpuWritten(const SharedPtr& bufferObject) { + if (!bufferObject) return; + if (!ResourceSubsystemEnabled()) { + bufferObject->MarkGpuWritten(); + return; + } + const MG_Pipe::MGPipeHandle res = HandleOfBuffer(bufferObject.get()); + if (MG_Pipe::MGPipeHandleIsNull(res) || MG_Pipe::gMGPipeCallbacks.OnGpuWritten == nullptr) { + // Deliberately NOT a fall-back to MarkGpuWritten: on this arm the resource + // family is switched over, and quietly reaching into the frontend object again + // would hide a missing handle or a missing reverse channel behind a picture + // that still looks right - which is what the subsystem A/B exists to expose. + MGLOG_E_ONCE("MGPipe: no reverse channel for the GPU-write announcement of buffer %u " + "(handle %s, OnGpuWritten %s)", + bufferObject->GetExternalIndex(), + MG_Pipe::MGPipeHandleIsNull(res) ? "missing" : "present", + MG_Pipe::gMGPipeCallbacks.OnGpuWritten == nullptr ? "unset" : "set"); + return; + } + // WHOLE RESOURCE, stated rather than implied: the extent is spelled as one range of + // kMGPipeWholeBuffer rather than as "zero ranges", because a zero count is the + // shape a fully NARROWED announcement will legitimately have once P8/P9 build the + // client's conservative set, and the two must not be the same record. + // + // THE OTHER HALF OF THE CONTRACT, which the range shape alone does not say: what + // this replaces, BufferObject::MarkGpuWritten, sets BOTH m_hasDefinedContent and + // m_gpuWritePending. The client's OnGpuWritten must set both too, or the next + // ResourceRespecify carries HasDefinedContent = 0, Ops_H_Respecify orphans instead + // of uploading, and the shader-written contents are dropped with nothing saying so. + const MG_Pipe::MGPRange whole{0, MG_Pipe::kMGPipeWholeBuffer}; + MG_Pipe::gMGPipeCallbacks.OnGpuWritten(res, 1, &whole); + } +#endif + // See the declaration: re-mints of a live resource's driver id. Written only on // the context thread (all re-mint sites run there), read only by the VAO sync. Uint64 g_bufferBackendIdGeneration = 0; void RegisterBufferBackendOps() { MG_State::GLState::SetBufferBackendOps(&g_glesBufferBackendOps); +#if MOBILEGL_PIPE_PUSH + // P3a: the handle-shaped table goes up beside it, at the same two bring-up sites + // and with the same lifetime. Registration is UNCONDITIONAL, exactly as + // BufferBackendOps' is: the subsystem bit is the FRONTEND's dispatch predicate + // (MGPipeResourceSubsystemEnabled reads the bit AND this table's presence), so a + // build with bit 7 clear registers a table nobody calls and the A/B stays a pure + // configuration question rather than a bring-up-order one. + MG_Pipe::MGPipeSetResourceOps(&g_glesResourceOps); +#endif // Frontend writes issued while ops were unregistered advanced change // serials with no per-op bump; re-open every draw-clean memo. BumpBufferMutationEpoch(); @@ -1367,6 +3127,11 @@ namespace MobileGL::MG_Backend::DirectGLES { if (MG_State::GLState::GetBufferBackendOps() == &g_glesBufferBackendOps) { MG_State::GLState::SetBufferBackendOps(nullptr); } +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::MGPipeGetResourceOps() == &g_glesResourceOps) { + MG_Pipe::MGPipeSetResourceOps(nullptr); + } +#endif // From here on frontend writes bypass the tracked ops entirely. BumpBufferMutationEpoch(); InvalidateArrayBufferBindingCache(); @@ -1391,6 +3156,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // handles (no GL) and let the next draw / texture upload recreate them. ResetRingForNewContext(g_uboRing); ResetRingForNewContext(g_unpackRing); +#if MOBILEGL_PIPE_PUSH + // R-11's server copies die with the context for the same reason the rings' ids do: + // the resource twins they are keyed by are about to be rebuilt against a new + // generation, and a shadow that outlived its twin would be looked up by a RECYCLED + // address on the next allocation - which is the quietest possible wrong answer. + MGL_SERVER_STAGED_DROP_ALL(); +#if MOBILEGL_BUILD_DISAGGREGATED + // tx's texture shadows die for the same reason, keyed by handle rather than address + // but with the same recycled-identity failure mode: a new context's allocator may + // hand out a {slot, gen} the old one's store still answers for. + MG_Remote::Server::ServerStagedTexture().DropAll(); +#endif +#endif } void ProcessDeferredBufferReleases() { @@ -1430,7 +3208,81 @@ namespace MobileGL::MG_Backend::DirectGLES { return static_cast(bufferObject->GetBackendResource().get()); } +#if MOBILEGL_PIPE_PUSH + // The same five questions, asked of the applier instead of the frontend object, with + // IDENTICAL semantics (D-A4): + // identity the slot table's twin at this handle, not GetBackendResource() + // size record.Desc.Width, not GetSize() + // freshness record.Serial vs syncedChangeSerial, not GetChangeSerial() + // map state record.HasLiveHostWrites AND the frontend's IsMapped() - see below + // the rest unchanged, and server-side to begin with + // + // HasLiveHostWrites is ALWAYS FALSE in P3a and is written by nobody; it is here so the + // phase that pushes persistent-mapped host writes can set it with no new record kind, + // and the assertion below is what stops that phase landing a silent semantic change. + // + // BECAUSE it is pinned false, it CANNOT stand in for the frontend's IsMapped() yet, and + // the probe still asks the object: an EMULATED (non-adopted) persistent map - under the + // 16 MiB adoption threshold, or with DisableLargeBufferAdoption, or with no + // EXT_buffer_storage - mutates the client's shadow with no call, no serial and no epoch, + // which is the entire reason the legacy probe asks a map question instead of a serial + // one. Answering only HasLiveHostWrites made such a buffer read draw-clean forever, so + // SyncPersistentMappedRange (D-N keeps it on the ensure path for all of P3a) was never + // reached again and the frame drew the last uploaded bytes with no diagnostic anywhere. + // The frontend read retires the moment P5 gives HasLiveHostWrites a producer, and it is + // the last frontend read in this function. + Bool IsBufferDrawCleanByHandle(MG_Pipe::MGPipeHandle res, const GLESBufferResource* resource, + const MG_State::GLState::BufferObject* frontend) { + if (!resource) return false; + const auto* twin = g_backendBufferResources.FindByHandle(res); + if (twin == nullptr || twin->get() != resource) return false; + if (resource->contextGeneration != g_bufferContextGeneration) return false; + if (resource->id == 0) return false; + if (resource->persistentMapped) { + return resource->persistentPtr != nullptr && resource->pendingResidentWrites.empty(); + } + const auto* record = ResourceRecordOf(res); + if (record == nullptr) return false; +#if MOBILEGL_PIPE_VERIFY && !MOBILEGL_BUILD_DISAGGREGATED + MOBILEGL_ASSERT(!record->HasLiveHostWrites, + "MGPipeResourceRecord::HasLiveHostWrites is set, but this build has no producer " + "for it - P5 b1's producer is MGPSubData::HasLiveHostWrites and is split-only"); +#endif + if (record->HasLiveHostWrites) return false; + // THE LAST FRONTEND READ IN THIS FUNCTION, AND P5 b1 RETIRES IT - under split + // only, because that is the only build where it is both wrong and replaceable. + // + // It asked the object whether it was mapped because HasLiveHostWrites was pinned + // false and an emulated persistent map mutates the shadow with no call, no serial + // and no epoch; answering from the record alone made such a buffer read + // draw-CLEAN forever, SyncPersistentMappedRange was never reached again, and the + // frame drew the last uploaded bytes with no diagnostic + // (MG_Test/SanityTest.cpp's DirectGLESBufferDrawProbe is exactly that case). + // + // TWO THINGS REPLACE IT AND BOTH HAD TO LAND FIRST: the record now carries the map + // state (the line above, from MGPSubData::HasLiveHostWrites), and the client + // pushes the mapped span by block at every validate point, so the serial moves for + // a write the application made with no API call. Under a spawn there is no object + // on this side to ask, which is why this was never going to stay a choice. + // + // A null object is the "no frontend to ask" case and is treated as "not mapped", + // which is what the record already says. + const Bool askTheObjectWhetherItIsMapped = + MG_Config::Transport == MG_Config::TransportMode::Monolith; + if (askTheObjectWhetherItIsMapped && frontend != nullptr && frontend->IsMapped()) return false; + if (resource->pendingRespecify || !resource->storageInitialized) return false; + if (!resource->pendingRanges.empty()) return false; + if (resource->storageSize != static_cast(record->Desc.Width)) return false; + return resource->syncedChangeSerial.load(std::memory_order_acquire) == record->Serial; + } +#endif + Bool IsBufferDrawClean(const MG_State::GLState::BufferObject* frontend, const GLESBufferResource* resource) { +#if MOBILEGL_PIPE_PUSH + if (ResourceSubsystemEnabled()) { + return IsBufferDrawCleanByHandle(HandleOfBuffer(frontend), resource, frontend); + } +#endif // Identity first: a respecify path can hand the frontend a NEW resource; the // memoed pointer is then stale (and only kept alive by the caller's shadow). if (!resource || resource != frontend->GetBackendResource().get()) return false; @@ -1453,12 +3305,282 @@ namespace MobileGL::MG_Backend::DirectGLES { return resource->syncedChangeSerial.load(std::memory_order_acquire) == frontend->GetChangeSerial(); } +#if MOBILEGL_PIPE_PUSH + // The handle arm of the ensure path. Same shape, same order, same branches; the four + // frontend reads become the applier's stored descriptor, its Serial and the shadow base + // the last content-carrying call handed over. + // + // It still takes the frontend object for ONE reason, recorded rather than hidden: + // BufferObject::SyncPersistentMappedRange() is one of the eleven Espryt sites + // ROADMAP/D-N explicitly keeps where it is for P3a (the per-site attribution table is + // P8's to execute). Every other line here is handle-shaped. When P8 moves that call to + // the client this signature loses its last frontend argument. + GLESBufferResource* EnsureBufferResourceForHandle(const SharedPtr& bufferObject, + MG_Pipe::MGPipeHandle res) { + auto* resource = GetOrCreateBufferResourceForHandle(res); + if (!resource) return nullptr; + + // The client's shadow base, RE-READ at every use, exactly as the legacy arm re-read + // MappedData() at every use. It is deliberately NOT resource->hostBytes here: two + // ordinary events move or free what a base recorded by an earlier call points at - + // a shadow resize (PipeResource::ResizeShadow is reserve + resize, so a grow past + // the reserve reallocates) and persistent-map adoption (the shadow is cleared and + // shrunk to fit) - and this path still holds the frontend object anyway, because + // D-N keeps SyncPersistentMappedRange here for all of P3a. hostBytes stays the + // fallback for the drains that have NO object (the readback flush), and it is + // nulled at both of those events. + const auto liveHostBase = [&resource, &bufferObject]() -> const Uint8* { +#if MOBILEGL_BUILD_DISAGGREGATED + // M-2 / codex 3 / ID-52 item 3: under an ACTIVE TRANSPORT the server's staged + // copy (resource->hostBytes, filled by MGL_SERVER_STAGED_ADOPT in Ops_H_SubData / + // Ops_H_FlushRange) is the ONLY authoritative base. Preferring the frontend + // object's MappedData() here - which under inproc is always non-null, same process + // - meant every reduced-path drain read the CLIENT's shadow and NEVER the server's, + // so a corrupt staged upload rendered the frontend's correct bytes and the R-2.5 + // 0xDD audit could not reach a draw. R-2 / table 3: honest inproc is inproc that + // does not read the client object's memory. Under monolith nothing changes. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return resource->hostBytes; + } +#endif + if (bufferObject) return bufferObject->MappedData(); + return resource->hostBytes; + }; + + if (resource->contextGeneration != g_bufferContextGeneration) { + // The id (if any) belonged to a destroyed ES context. + resource->id = 0; + resource->storageInitialized = false; + resource->storageSize = 0; + resource->pendingRespecify = true; + resource->pendingRanges.clear(); + resource->pendingResidentWrites.clear(); + resource->contextGeneration = g_bufferContextGeneration; + resource->persistentMapped = false; + resource->persistentPtr = nullptr; + resource->immutableStorage = false; +#if MOBILEGL_BUILD_DISAGGREGATED + // m-5 / codex 5: OnBackendContextDestroyed ran MGL_SERVER_STAGED_DROP_ALL(), which + // frees every server shadow but does NOT null the hostBytes that name them - so a + // twin that SURVIVES a context loss still carries a base into the freed allocation. + // Null it here, where a surviving twin is re-armed - but ONLY when the shadow is + // really gone. This block also runs on a twin's FIRST ensure (contextGeneration + // starts mismatched), and there the shadow a preceding resource_subdata just staged + // is still live; nulling it then would drop the reduced path's own bytes (it did, + // and TriangleScenario read the wrong VBO). HasShadow is the discriminator: false + // after DropAll, true after an ordinary Adopt. + // + // TRANSPORT-GUARDED like the other two split hunks in this file (review v2 N-7): + // under MONOLITH transport in a disaggregated build the store never copies, Adopt + // never sets m_any, HasShadow answers false for everything, and this null would + // run on every twin's first ensure - harmless there only because liveHostBase() + // prefers MappedData() under monolith, and "under monolith nothing changes" should + // be true by construction rather than by luck. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !ServerStaged().HasShadow(resource)) { + resource->hostBytes = nullptr; + } +#endif + } + + // An immutable store nothing maps any more, retired here on the thread that can. + if (resource->immutableStorage && !resource->persistentMapped && resource->id != 0) { + NoteBufferIdDeleted(resource->id); + ++g_bufferBackendIdGeneration; + g_GLESFuncs.glDeleteBuffers(1, &resource->id); + resource->id = 0; + resource->immutableStorage = false; + resource->storageInitialized = false; + resource->storageSize = 0; + resource->pendingRespecify = true; + } + + // Zero-copy coherent persistent buffer: nothing to (re)upload at draw time. + if (resource->persistentMapped && resource->persistentPtr && resource->id != 0) { + DrainResidentWritesNow(*resource); + return resource; + } + + if (resource->id == 0) { + const SizeT poolSize = ResourceWidthOf(res); + // The pool DECISION is the legacy one, unchanged: extent and residence only. + // An earlier cut added "and a shadow base was recorded", which silently stopped + // a buffer recycling pool ids - a change to D-F's pool behaviour, not to "the + // source of bytes" the conversion is allowed to move, and one that moves + // AcquireFromPool call counts StreamedArenaScenario observes. + const Uint reused = + (poolSize > 0 && !resource->persistentMapped) ? AcquireFromPool(poolSize) : 0; + if (reused != 0) { + resource->id = reused; + resource->storageSize = poolSize; + resource->storageInitialized = true; + resource->pendingRespecify = false; + BindBufferId(TempBufferTarget, reused); + // M-3 / codex 4: this is a WHOLE-STORE upload from the base, and under split + // the base is the server shadow (M-2). For a store whose content the + // application SUPPLIED, zero-filled bytes past the staged coverage are not the + // application's - uploading them is the silent data loss the M-6 ruling + // forbids, and a gap is a missing record: Fatal by name. For a store the + // application ORPHANED the gap is its own undefined content and the upload is + // legal (ResourceContentIsDeclared, above). RequireCoverage is a no-op for the + // legacy arm's MappedData() and for a non-copying store. +#if MOBILEGL_BUILD_DISAGGREGATED + if (ResourceContentIsDeclared(res)) { + MGL_SERVER_STAGED_REQUIRE(*resource, liveHostBase(), 0, poolSize, + "pool_reuse_whole_store"); + } +#endif + g_GLESFuncs.glBufferSubData(TempBufferTarget, 0, (GLsizeiptr)poolSize, liveHostBase()); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(poolSize)); + } + { + const std::lock_guard lock(resource->pendingMutex); + resource->pendingRanges.clear(); + resource->pendingResidentWrites.clear(); + } + resource->syncedChangeSerial = ResourceSerialOf(res); + } else { + g_GLESFuncs.glGenBuffers(1, &resource->id); + if (resource->id == 0) { + MGLOG_E_ONCE("Failed to generate buffer object."); + MGLOG_E_ONCE("ES glGetError(): %s", + MG_Util::ConvertGLEnumToString(g_GLESFuncs.glGetError()).c_str()); + return resource; + } + resource->storageInitialized = false; + resource->pendingRespecify = true; + } + } + + // D-N keeps this call here for P3a. It can queue ranges and move the record, so + // everything below is read AFTER it. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.8): under an active transport the frontend accessor is a + // layer-1 surface ("buffer-legacy-arm") and the client's own pre-verb + // persistent-map push is the only producer; the call is skipped outright. Under + // monolith D-N's placement stands. + if (bufferObject && MG_Config::Transport == MG_Config::TransportMode::Monolith) { + bufferObject->SyncPersistentMappedRange(); + } +#else + if (bufferObject) bufferObject->SyncPersistentMappedRange(); +#endif + + const auto* record = ResourceRecordOf(res); + const SizeT size = record != nullptr ? static_cast(record->Desc.Width) : 0; + if (size == 0) { + return resource; + } + const Uint64 serial = record->Serial; + const GLenum usage = MG_Util::ConvertBufferUsageToGLEnum(static_cast(record->Desc.Usage)); + // Read AFTER SyncPersistentMappedRange above, and through liveHostBase for the + // reason written where it is declared. + const Uint8* const hostBase = liveHostBase(); + // AND PUBLISH IT, because the readers that have NO frontend object read + // resource->hostBytes and nothing else: the readback flush (Ops_H_Readback), the + // kill-switch map arm of Ops_H_FlushRange, and the fp64 narrowing + // (SyncFloat64AttributeAsFloat32ByHandle, whose legacy counterpart reads + // bufferObject->MappedData()). A twin is created LAZILY - D-A2's row makes + // Ops_H_Create a no-op - so the very first resource_respecify of a buffer finds + // FindBufferResourceForHandle == nullptr and its base is dropped on the floor; with + // glBufferData(..., data) followed by no further content call (the ordinary static + // vertex array) hostBytes then stayed null for the object's whole life and the fp64 + // narrowing refused every draw, disabling the array. This is the one place that both + // holds the object and runs before every draw that uses the store, so it is where the + // base is refreshed. Only reached for a NON-adopted resource (the adopted arm + // returned above), so C-2's rule is intact: an adopted store's hostBytes stays null + // and its bytes are read through persistentPtr. + if (hostBase != nullptr) resource->hostBytes = hostBase; + // "DOES THE SHADOW THIS PATH IS ABOUT TO UPLOAD HOLD MEANINGFUL BYTES?" - and it has + // to be asked of the SAME thing the bytes come from. The legacy arm pairs + // bufferObject.MappedData() with bufferObject.HasDefinedContent() (RespecifyStorageNow) + // and this arm pairs the same two, so the source of the bytes and the statement about + // them can never disagree. + // + // P5c (hd, CONTRACT-P5C §3.6 / B1): with an active transport the answer is the + // DESCRIPTOR's bit - which the client publishes on ResourceCreate/Respecify - OR the + // staged coverage the content records behind it delivered. The coverage half is what + // keeps the answer equal to the frontend flag's in the one case the descriptor alone + // cannot see: an ORPHANING respecify (descriptor clear) followed by glBufferSubData, + // which defines content without a new descriptor - the bytes crossed as + // resource_subdata and the staged store's coverage is exactly "content defined since + // the last orphan". With no coverage and a clear bit the store is undefined by the + // application's own declaration and the upload stays a pure NULL reallocation. + const Bool shadowHasContent = +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? (record->Desc.HasDefinedContent != 0 || + ServerStaged().CoveredRunCount(resource) != 0) + : +#endif + (bufferObject ? bufferObject->HasDefinedContent() : (record->Desc.HasDefinedContent != 0)); + const void* initialData = shadowHasContent ? hostBase : nullptr; + // M-3 / codex 4: a RespecifyStorageWith(..., initialData != nullptr) is a WHOLE-STORE + // [0, size) upload from the base, so it owes the same coverage the pending-range drain + // below owes - the two respecify arms were the readers M-6's "never widened" rule did + // not reach. No-op for the legacy arm's MappedData() and for a non-copying store; a + // Fatal{StageSnapshotTooNarrow, "respecify_whole_store"} under split when the + // DESCRIPTOR says the application supplied the content and the shadow's coverage does + // not span the store (a missing record), instead of uploading its zero-fill as content. + // NOT for a store the application orphaned: there `shadowHasContent` is the frontend + // object's flag, which the streaming idiom's partial glBufferSubData flips to true, and + // the bytes it did not write are undefined by its own glBufferData(NULL) - the rule at + // ResourceContentIsDeclared. Round 2 refused that idiom and aborted six joint entries. + const auto requireWholeStoreCoverage = [&]() { +#if MOBILEGL_BUILD_DISAGGREGATED + if (initialData != nullptr && record->Desc.HasDefinedContent != 0) { + MGL_SERVER_STAGED_REQUIRE(*resource, static_cast(initialData), 0, + size, "respecify_whole_store"); + } +#else + (void)initialData; +#endif + }; + + if (resource->pendingRespecify || !resource->storageInitialized || resource->storageSize != size) { + requireWholeStoreCoverage(); + RespecifyStorageWith(*resource, size, usage, initialData, serial); + } else if (!resource->pendingRanges.empty()) { + // Same rule as Ops_H_Readback's, at the draw-time drain: with no frontend object + // `hostBase` is the server shadow and every queued range must be staged. + MGL_SERVER_STAGED_REQUIRE_PENDING(*resource, hostBase, size, "ensure_flush_pending"); + FlushPendingRangesFrom(*resource, hostBase, size); + resource->syncedChangeSerial = serial; + } else if (resource->syncedChangeSerial != serial) { + // Mutations this backend could not track (the table was unregistered between + // contexts); re-upload everything. + requireWholeStoreCoverage(); + RespecifyStorageWith(*resource, size, usage, initialData, serial); + } + return resource; + } +#endif + GLESBufferResource* EnsureBufferResource(const SharedPtr& bufferObject) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (!bufferObject) return nullptr; +#if MOBILEGL_PIPE_PUSH + if (ResourceSubsystemEnabled()) { + const MG_Pipe::MGPipeHandle res = HandleOfBuffer(bufferObject.get()); + if (MG_Pipe::MGPipeHandleIsNull(res)) { + // The client never created this resource on the wire. There is deliberately + // no fall-back to the legacy arm: silently twinning it off the frontend + // object would hide a missing emission behind a working picture, which is + // exactly what the subsystem A/B exists to make visible. + MGLOG_E_ONCE("MGPipe: buffer %u has no resource handle - the resource family is switched " + "over but nothing emitted resource_create for it", + bufferObject->GetExternalIndex()); + return nullptr; + } + return EnsureBufferResourceForHandle(bufferObject, res); + } +#endif + auto* resource = static_cast(bufferObject->GetBackendResource().get()); if (!resource) { auto newResource = MakeShared(); @@ -1482,6 +3604,10 @@ namespace MobileGL::MG_Backend::DirectGLES { resource->persistentPtr = nullptr; resource->immutableStorage = false; } + // m-5: the LEGACY arm (EnsureBufferResource) is reached only under monolith/push, where + // liveHostBase reads the frontend object's MappedData rather than a server shadow, so + // there is no freed server base to null here - the split freed-base hazard lives in + // EnsureBufferResourceForHandle above, guarded by HasShadow. // An immutable store nothing maps any more: a respecification of a buffer that // had been persistently mapped, which Ops_Respecify could not retire because it @@ -1523,6 +3649,13 @@ namespace MobileGL::MG_Backend::DirectGLES { BindBufferId(TempBufferTarget, reused); g_GLESFuncs.glBufferSubData(TempBufferTarget, 0, (GLsizeiptr)poolSize, bufferObject->MappedData()); + if (MG_Util::PipeStats::Enabled()) { + // The pool-recycle reseed is a whole-buffer upload on the hot path, + // not a bookkeeping detail: it moves the same bytes a fresh + // glBufferData would. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(poolSize)); + } { const std::lock_guard lock(resource->pendingMutex); resource->pendingRanges.clear(); @@ -1554,7 +3687,12 @@ namespace MobileGL::MG_Backend::DirectGLES { resource->storageSize != bufferObject->GetSize()) { RespecifyStorageNow(*resource, *bufferObject); } else if (!resource->pendingRanges.empty()) { +#if MOBILEGL_PIPE_PUSH + // The shared ladder, by ID-11 - see the note where the forwarder would be. + FlushPendingRangesFrom(*resource, bufferObject->MappedData(), bufferObject->GetSize()); +#else FlushPendingRangesNow(*resource, *bufferObject); +#endif resource->syncedChangeSerial = bufferObject->GetChangeSerial(); } else if (resource->syncedChangeSerial != bufferObject->GetChangeSerial()) { // Ops could not track some writes (e.g. the ops table was @@ -2041,14 +4179,519 @@ namespace MobileGL::MG_Backend::DirectGLES { return RingAllocate(g_unpackRing, size, outOffset); } - void* UnpackRingMappedPtr() { return g_unpackRing.store.mappedPtr; } - Uint UnpackRingBufferId() { return g_unpackRing.store.id; } - SizeT UnpackRingMaxBytes() { return kUnpackRingMaxBytes; } + void* UnpackRingMappedPtr() { return g_unpackRing.store.mappedPtr; } + Uint UnpackRingBufferId() { return g_unpackRing.store.id; } + SizeT UnpackRingMaxBytes() { return kUnpackRingMaxBytes; } + + void UnpackRingOnPresent() { RingOnPresent(g_unpackRing); } + + void UploadRingOnPresent() { RingOnPresent(g_uploadRing); } + } // namespace BufferImpl + +#if MOBILEGL_PIPE_PUSH + // ---- P4a (D-K3): the four family arm resolvers, beside BufferImpl's two ---- + // + // They reuse BufferImpl's ClassifyPipeSubsystemArm / StopOnArmlessPipeSubsystem unchanged: + // one voice for "the operator left no arm at all", so the six families cannot disagree + // about what an armless verdict means or about whether it stops. + // + // Every one of the four answers legacyArmSurvivesLegacyMemos = FALSE, because every one of + // the four pre-handle arms is compiled under MOBILEGL_PIPE_LEGACY_MEMOS (Managers.h names + // them per family). So all four can reach NoArm and all four stop with the named + // Fatal{PipeLegacyMemosDisabled, ...} rather than running the very arm the operator asked + // to have taken away and handing back a result measured on it. + namespace { + // Shared by the three dependent families, so the refusal reads the same way three times + // and a future fourth cannot invent a different wording. Returns true when the + // dependency is missing, having said so once. + Bool PipeSubsystemDependencyMissing(Uint64 mask, Uint64 dependencyBit, const char* what) { + if ((mask & dependencyBit) != 0) return false; + MGLOG_E("MGPipe: %s - REFUSING the dependent bit and running the legacy arm. " + "Set both bits, or clear both", + what); + return true; + } + + // The release-build VOICE for StateBackendObjectRegistry::GetOrCreateByHandle's two + // silent refusals, shared by the kinds P4a re-keys so the wording cannot drift between + // them. It is the shape GetOrCreateBufferResourceForHandle gives P3a's buffer family, + // lifted into one template because a copy per kind is a chance per kind to write one of + // them differently. + // + // ITS CALLERS IN THIS PACKAGE ARE THE FRAMEBUFFER ATTACHMENT WALK'S TWO + // (FramebufferImpl::SyncAttachmentSurface: the texture registry and the renderbuffer + // registry), because that is where a HANDLE arrives in a payload - MGPSurface::Res - + // and an ADOPTION is what the arm owes rather than a lookup. The other three re-keyed + // kinds resolve at sites in DirectGLES.cpp (package E's SyncCurrentFBO, the lazy + // sampler mint and SyncCurrentProgram), so their first adoption is E's to write; this + // template is what they call so all five say the same thing. + // + // WHY A VOICE AT ALL: the table asserts, and MOBILEGL_ASSERT compiles out at INFO - + // which all three gate builds and every shipped build are - so an object that silently + // stops being twinned is exactly the failure mode the refusal exists to replace. + // + // Returns null on the legacy arm too, so a caller that reaches this without checking + // its family's arm gets nothing rather than a twin the legacy arm would never see. + // + // SPLIT IN TWO (review N-3), and the split is what makes the two refusals REACHABLE. + // Both of this package's call sites stand behind a cross-check that the record's Res + // equals registry.HandleOf(frontendObject) - the LIVE handle - so by the time the + // adoption ran, the slot was in bounds and the generation was current BY CONSTRUCTION + // and neither refusal below could ever fire: the letter of M-4 was closed and its + // substance was not. The record's handle is the UNTRUSTED input and the frontend is the + // corroboration, so the validity question is asked FIRST, on its own, at both sites; + // the adoption then performs the resolve. Asking it first is also side-effect free, + // which the ordering rule N-1 states for the framebuffer refusal wants here too: + // GetOrCreateByHandle can reset an incumbent twin, and a handle this predicate rejects + // must not reach it. + template + Bool PipeTwinHandleIsAdoptable(Registry& registry, MG_Pipe::MGPipeHandle handle, const char* kindName) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return false; + if (handle.Slot >= Registry::SlotTable::kMaxHandleSlot) { + MGLOG_E_ONCE("MGPipe: %s handle slot %u is past the backend table's %u bound - " + "refusing to twin it", + kindName, handle.Slot, Registry::SlotTable::kMaxHandleSlot); + return false; + } + const Uint32 liveGen = registry.LiveGenAt(handle.Slot); + if (liveGen != 0 && liveGen > handle.Gen) { + // SlotTables.h:301-321: forward is a recycle and resets the twin, BACKWARD is + // refused, because adopting it would release the incumbent LIVE twin's driver + // id and then stamp the slot back to the dead object's generation, after which + // the incumbent's own FindByHandle refuses it and it is silently handed a + // fresh, empty twin - a leak AND an object that loses its storage with no + // diagnostic. That is the shape commit d7655247 fixed on the buffer side. + MGLOG_E_ONCE("MGPipe: %s handle {%u, %u} names a generation BEHIND the live " + "twin's %u - refusing rather than dropping the incumbent's driver " + "object", + kindName, handle.Slot, handle.Gen, liveGen); + return false; + } + return true; + } + + template + typename Registry::BackendPtr* AdoptTwinByHandle(Registry& registry, MG_Pipe::MGPipeHandle handle, + const char* kindName) { + if (!PipeTwinHandleIsAdoptable(registry, handle, kindName)) return nullptr; + return registry.GetOrCreateByHandle(handle); + } + } // namespace + + Bool ResolveFramebufferSubsystemArm() { + const Uint64 mask = MG_Config::Features.PipePush; + const Bool bitSet = (mask & MG_Pipe::kMGPipeSubsystemFramebuffer) != 0; + Bool refused = false; + if (bitSet) { + // BIT 9 REQUIRES BIT 10. Every MGPSurface::Res in the record names a Texture or a + // Renderbuffer handle, and only bit 10 puts twins in those two slot tables; without + // it every attachment lookup would miss and the walk would leave the driver + // framebuffer holding whatever the last owner attached. The mirror pair (bit 10 set, + // bit 9 clear) is FINE: the legacy FBO sync reaches the texture twin through + // SyncTextureObjectToBackend, which dispatches to the handle arm by itself. + refused = PipeSubsystemDependencyMissing( + mask, MG_Pipe::kMGPipeSubsystemTextureResources, + "kMGPipeSubsystemFramebuffer (bit 9) is set but kMGPipeSubsystemTextureResources " + "(bit 10) is clear; MGPSurface::Res names a Texture or Renderbuffer handle and " + "only bit 10 populates those slot tables"); + // D-C3: the wire array is Color[8] and this is the driver's RAW ES cap, which is + // NOT clamped to 8 on the GLES path (ValidateColorAttachmentInRange rejects at or + // above it, and BackendObject_DirectGLES publishes it verbatim). Every campaign + // device reports 8 and ES 3.2's minimum is 8 - but a driver reporting more would + // make the record silently truncate, and truncating silently is the bug class this + // phase is closing, while widening the payload is a wire change nobody has evidence + // for. So: refuse the bit, name the cap, run the legacy arm. + if (!refused && + static_cast(std::max(g_GLESCapabilities.MaxColorAttachments, 0)) > + MG_Pipe::kMGPipeMaxColorAttachments) { + MGLOG_E("MGPipe: this driver reports GL_MAX_COLOR_ATTACHMENTS = %d, above " + "MGPFramebufferState::Color[%u]'s wire width - REFUSING " + "kMGPipeSubsystemFramebuffer (bit 9) rather than truncating the record, " + "and running the legacy framebuffer arm", + g_GLESCapabilities.MaxColorAttachments, MG_Pipe::kMGPipeMaxColorAttachments); + refused = true; + } + } + const BufferImpl::PipeSubsystemArmVerdict verdict = BufferImpl::ClassifyPipeSubsystemArm( + bitSet && !refused, MG_Config::Features.PipeLegacyMemos, + /*legacyArmSurvivesLegacyMemos=*/false); + if (verdict == BufferImpl::PipeSubsystemArmVerdict::NoArm) { + BufferImpl::StopOnArmlessPipeSubsystem( + "MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemFramebuffer (bit 9) clear (or refuses " + "it) and MOBILEGL_PIPE_LEGACY_MEMOS=0 disables the pre-handle g_fboSynced* arm"); + } + const Bool enabled = verdict == BufferImpl::PipeSubsystemArmVerdict::Handles; + MGLOG_D("MGPipe: Espryt framebuffer family runs the %s arm", enabled ? "handle" : "legacy"); + return enabled; + } + + Bool ResolveTextureResourceSubsystemArm() { + const Uint64 mask = MG_Config::Features.PipePush; + const Bool bitSet = (mask & MG_Pipe::kMGPipeSubsystemTextureResources) != 0; + Bool refused = false; + if (bitSet) { + // 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, so + // with bit 7 clear every glTexBuffer would resolve to no storage at all. The mirror + // pair (bit 7 set, bit 10 clear) is FINE and is P3a's shipped configuration. + refused = PipeSubsystemDependencyMissing( + mask, MG_Pipe::kMGPipeSubsystemResources, + "kMGPipeSubsystemTextureResources (bit 10) is set but kMGPipeSubsystemResources " + "(bit 7) is clear; a buffer texture's BufferForTexBuffer names a Buffer handle " + "and only bit 7 populates the resource slot table"); + // BIT 10 REQUIRES BIT 11 - D-K2's FOURTH ROW (ID-14/ID-15), and it is the exact + // mirror of ResolveVertexInputSubsystemArm's bit-8-requires-bit-7 refusal above: + // the same "the handle arm resolves through a slot table only the other bit + // populates" shape, refused here rather than half-run, with the legacy arm as the + // fall-back. MGPTextureParams::BuiltinSampler is a SamplerCso HANDLE and only bit 11 + // mints sampler CSOs (contract c0b's four unconditional mints deliberately exclude + // it), so with bit 11 clear every set_texture_params would carry a null there - and + // the applier's verdict for a null BuiltinSampler is Fatal{ProtocolCorruption}, not + // a decline. The brief's original sentence "bit 10 without 11 is fine" is WITHDRAWN + // for P4a as built; package F pins this arm at 0x5ff. + // + // THE TWO MIRROR PAIRS THAT STAY FINE, said out loud because an unreachable branch + // that says something different is how the reachable one drifts: + // - bit 7 set, bit 10 clear: P3a's shipped configuration (above). + // - bit 11 set, bit 10 clear: refused one function down, by the bit-11-requires- + // bit-10 row, so the pair is symmetric and neither half can run alone. That + // symmetry is the point: bits 10 and 11 are now ONE arm with two switches, and + // the only two masks that reach the texture handle arm are "both set" and + // "neither set". + if (!refused) { + refused = PipeSubsystemDependencyMissing( + mask, MG_Pipe::kMGPipeSubsystemSamplers, + "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}"); + } + } + const BufferImpl::PipeSubsystemArmVerdict verdict = BufferImpl::ClassifyPipeSubsystemArm( + bitSet && !refused, MG_Config::Features.PipeLegacyMemos, + /*legacyArmSurvivesLegacyMemos=*/false); + if (verdict == BufferImpl::PipeSubsystemArmVerdict::NoArm) { + BufferImpl::StopOnArmlessPipeSubsystem( + "MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemTextureResources (bit 10) clear (or " + "refuses it) and MOBILEGL_PIPE_LEGACY_MEMOS=0 disables the pre-handle texture " + "cheap-gate trio"); + } + const Bool enabled = verdict == BufferImpl::PipeSubsystemArmVerdict::Handles; + MGLOG_D("MGPipe: Espryt texture-resource family runs the %s arm", enabled ? "handle" : "legacy"); + return enabled; + } + + Bool ResolveSamplerSubsystemArm() { + const Uint64 mask = MG_Config::Features.PipePush; + const Bool bitSet = (mask & MG_Pipe::kMGPipeSubsystemSamplers) != 0; + Bool refused = false; + if (bitSet) { + // BIT 11 REQUIRES BIT 10, and this is the pair G12 drives at 0x9ff. Every + // MGPBoundView::Texture and every MGPImageView::Res names a Texture handle, and + // only bit 10 puts one in the slot table; without it every per-unit lookup would + // miss and the walk would `continue` WITHOUT unbinding - i.e. every draw would + // sample through whatever the unit last held, which is exactly the shape the + // bit-8-without-bit-7 refusal exists to prevent one family over. + // + // THE MIRROR PAIR (bit 10 set, bit 11 clear) IS NOT FINE EITHER, and that is D-K2's + // fourth row (ID-14/ID-15): it is refused by ResolveTextureResourceSubsystemArm + // above, because MGPTextureParams::BuiltinSampler is a SamplerCso handle only bit 11 + // mints. So this dependency is SYMMETRIC - the two bits are one arm with two + // switches - and this sentence used to claim the opposite. + refused = PipeSubsystemDependencyMissing( + mask, MG_Pipe::kMGPipeSubsystemTextureResources, + "kMGPipeSubsystemSamplers (bit 11) is set but kMGPipeSubsystemTextureResources " + "(bit 10) is clear; every MGPBoundView::Texture and MGPImageView::Res names a " + "Texture handle and only bit 10 populates that slot table"); + } + const BufferImpl::PipeSubsystemArmVerdict verdict = BufferImpl::ClassifyPipeSubsystemArm( + bitSet && !refused, MG_Config::Features.PipeLegacyMemos, + /*legacyArmSurvivesLegacyMemos=*/false); + if (verdict == BufferImpl::PipeSubsystemArmVerdict::NoArm) { + BufferImpl::StopOnArmlessPipeSubsystem( + "MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemSamplers (bit 11) clear (or refuses " + "it) and MOBILEGL_PIPE_LEGACY_MEMOS=0 disables UnitSamplerLookupMemo's WeakPtr " + "arm and SamplerPassMemo's raw-pointer rows"); + } + const Bool enabled = verdict == BufferImpl::PipeSubsystemArmVerdict::Handles; + MGLOG_D("MGPipe: Espryt sampler family runs the %s arm", enabled ? "handle" : "legacy"); + return enabled; + } + + Bool ResolveProgramSubsystemArm() { + // Bit 12 depends on NOTHING, and that is said out loud rather than left as an absence: + // a ShaderCso handle names no texture and no buffer, the archive rides beside the + // record as a companion pointer, and the eight extra inputs the server still + // specialises on (D-H5) are read from state this backend already holds. + const Bool bitSet = (MG_Config::Features.PipePush & MG_Pipe::kMGPipeSubsystemPrograms) != 0; + const BufferImpl::PipeSubsystemArmVerdict verdict = BufferImpl::ClassifyPipeSubsystemArm( + bitSet, MG_Config::Features.PipeLegacyMemos, /*legacyArmSurvivesLegacyMemos=*/false); + if (verdict == BufferImpl::PipeSubsystemArmVerdict::NoArm) { + BufferImpl::StopOnArmlessPipeSubsystem( + "MOBILEGL_PIPE_PUSH leaves kMGPipeSubsystemPrograms (bit 12) clear and " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 disables g_programTwinLookupMemo"); + } + const Bool enabled = verdict == BufferImpl::PipeSubsystemArmVerdict::Handles; + MGLOG_D("MGPipe: Espryt program family runs the %s arm", enabled ? "handle" : "legacy"); + return enabled; + } + + // ---- P4a: the record readers (Managers.h documents the contract) ---- + namespace { + // One shape for all five, so a kind cannot grow a different liveness rule than its + // neighbours: index the applier's own dense table, refuse a slot it has not grown to, + // refuse a record that is not Live, and refuse a generation that has moved on under + // the caller. The last of the three is the one that matters: a stale handle resolving + // to its successor's record is how a twin ends up describing another object's storage. + template + const Record* PipeRecordAt(const Vector& table, MG_Pipe::MGPipeHandle handle) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr; + if (handle.Slot >= table.size()) return nullptr; + const Record& record = table[handle.Slot]; + if (!record.Live || record.Gen != handle.Gen) return nullptr; + return &record; + } + } // namespace + + const MG_Pipe::MGPipeResourceRecord* PipeTextureRecordForHandle(MG_Pipe::MGPipeHandle res) { + return PipeRecordAt(MG_Pipe::MGPipeApplier().TextureResources, res); + } + + const MG_Pipe::MGPipeResourceRecord* PipeRenderbufferRecordForHandle(MG_Pipe::MGPipeHandle res) { + return PipeRecordAt(MG_Pipe::MGPipeApplier().RenderbufferResources, res); + } + + MobileGL::TextureTarget PipeTextureTargetForHandle(MG_Pipe::MGPipeHandle res) { + const auto* record = PipeTextureRecordForHandle(res); + if (record == nullptr) return MobileGL::TextureTarget::Unknown; + // THE INVERSE OF MG_Pipe::MGPipeResourceTargetForTextureTarget, COMPUTED FROM IT rather + // than restated as a second table. MGPipeTypes.h already static_asserts that the + // forward map covers the frontend enum and that it separates the one pair anybody has + // ever folded together (2D vs rectangle), so a linear search over a dozen enumerators + // is total, cannot drift, and runs at most once per image unit per sweep. A second + // switch here is exactly how the two spellings end up disagreeing about a target that + // was added to only one of them. + for (SizeT i = 0; i < static_cast(MobileGL::TextureTarget::TextureTargetCount); ++i) { + const auto target = static_cast(i); + if (MG_Pipe::MGPipeResourceTargetForTextureTarget(target) == + static_cast(record->Desc.Target)) { + return target; + } + } + return MobileGL::TextureTarget::Unknown; + } + + const MG_Pipe::MGPipeSamplerCsoRecord* PipeSamplerCsoRecordForHandle(MG_Pipe::MGPipeHandle cso) { + return PipeRecordAt(MG_Pipe::MGPipeApplier().SamplerCsos, cso); + } + + const MG_Pipe::MGPipeSamplerViewRecord* PipeSamplerViewRecordForHandle(MG_Pipe::MGPipeHandle view) { + return PipeRecordAt(MG_Pipe::MGPipeApplier().SamplerViewCsos, view); + } + + const MG_Pipe::MGPipeShaderCsoRecord* PipeShaderCsoRecordForHandle(MG_Pipe::MGPipeHandle cso) { + // The composite band is a CLIENT-side indexing detail (contract D13) and the server + // must not learn about it: one reader, one answer, and the band's own table is indexed + // by (slot - base) exactly as the allocator's is. + if (MG_Pipe::MGPipeIsCompositeShaderSlot(cso.Slot)) { + const auto& band = MG_Pipe::MGPipeApplier().CompositeShaderCsos; + const Uint32 index = cso.Slot - MG_Pipe::kMGPipeShaderCsoCompositeSlotBase; + if (index >= band.size()) return nullptr; + const auto& record = band[index]; + if (!record.Live || record.Gen != cso.Gen) return nullptr; + return &record; + } + return PipeRecordAt(MG_Pipe::MGPipeApplier().ShaderCsos, cso); + } + + // N-7: D KEYS ON THE UPLOAD HALF; WIRE (AccumulatePendingUpload, PipeApply.cpp:814) KEYS ON + // THE WHOLE PACKED FIELD. That asymmetry is resolved HERE, in D's favour and with the + // reason, rather than by widening the two comparisons: D's question is per (uploadTarget, + // level) because that is the granularity its upload loop iterates - it is handed a + // TextureUploadTarget and a level and has no second key to offer - while wire's is per + // record, where the whole field arrived. They agree because a texture's resource-target + // byte is constant for the object's life; if that ever stopped holding, wire would store + // two entries where D sees one and D's consume would strand the second in the set for ever + // (a full re-upload of that level on every sync, silently). + // + // So the low byte is CROSS-CHECKED instead of ignored. The comparand is the resource's own + // descriptor Target, which B fills from exactly the expression the sub-data's low byte + // comes from (TextureEmit.h:223 / :517 against :1037), so the two are carried and equal by + // construction and any disagreement is a seam defect, not a shape to learn. This is also + // MGPResourceDesc::Target's first reader in this package (esprytobj-v2.md §2(6) recorded it + // as having none). + static void NotePipeSubDataTargetLowByte(const MG_Pipe::MGPipeResourceRecord& record, Uint16 packedTarget, + Uint16 level) { + const Uint8 carried = MG_Pipe::MGPipeSubDataResourceTargetOf(packedTarget); + if (carried == record.Desc.Target) return; + MGLOG_E_ONCE("MGPipe: a pending upload for level %u carries resource target %u while its " + "resource's descriptor says %u - D matches on the upload half only, so this " + "entry was matched anyway; the two sides disagree about what the resource is", + static_cast(level), static_cast(carried), + static_cast(record.Desc.Target)); + } + + const MG_Pipe::MGPipeResourceRecord::PendingUpload* FindPipeTextureUpload( + const MG_Pipe::MGPipeResourceRecord& record, Uint16 uploadTarget, Uint16 level) { + // Linear, and deliberately so: the set holds one entry per DIRTY (uploadTarget, level) + // of one texture, which is at most six faces x the level count and in practice one or + // two entries. A map would cost an allocation per texture per frame to save a walk of + // three. + // + // THE STORED KEY IS THE PACKED MGPSubData::Target (ID-12): low byte + // MGPipeResourceTarget, HIGH byte TextureUploadTarget. The applier stores the whole + // field verbatim (PipeApply.cpp's `entry.UploadTarget = record.Target`) because + // MGPRespecifiedLevel::UploadTarget has to pair with it byte for byte, so the decode is + // the reader's - here and at the consume, the only two places D compares it. `uploadTarget` + // is static_cast(MobileGL::TextureUploadTarget), i.e. already the half. + // Without the decode every texture upload would be dropped silently: Texture1D is 0 and + // Texture2D is 1, which collide with the resource-target byte of Buffer and Tex1D. + for (const auto& pending : record.PendingUploads) { + if (MG_Pipe::MGPipeSubDataUploadTargetOf(pending.UploadTarget) == static_cast(uploadTarget) && + pending.Level == level) { + NotePipeSubDataTargetLowByte(record, pending.UploadTarget, level); + return &pending; + } + } + return nullptr; + } + + void ConsumePipeTextureUpload(MG_Pipe::MGPipeHandle res, Uint16 uploadTarget, Uint16 level) { + // Re-resolved rather than taken as a reference from the caller: between the read and + // the consume the caller has issued driver work, and the applier's Vector may have been + // grown by a re-entrant call in between. Cheap - the resolve is a bounds check and two + // compares - and it is the difference between a dangling reference and a no-op. + auto& applier = MG_Pipe::MGPipeApplier(); + if (MG_Pipe::MGPipeHandleIsNull(res) || res.Slot >= applier.TextureResources.size()) return; + auto& record = applier.TextureResources[res.Slot]; + if (!record.Live || record.Gen != res.Gen) return; + for (SizeT index = 0; index < record.PendingUploads.size(); ++index) { + const auto& pending = record.PendingUploads[index]; + // The same HIGH-byte decode FindPipeTextureUpload makes, and it has to be the same + // one: a consume that missed here after a find that hit would leave the entry in the + // set forever and re-upload the level on every sync. + if (MG_Pipe::MGPipeSubDataUploadTargetOf(pending.UploadTarget) != static_cast(uploadTarget) || + pending.Level != level) { + continue; + } + NotePipeSubDataTargetLowByte(record, pending.UploadTarget, level); + // Swap-and-pop: the set is unordered by construction (it is a per-(target, level) + // accumulation, not a queue), and an ordered erase would be quadratic over a full + // cube-map drain. + record.PendingUploads[index] = std::move(record.PendingUploads.back()); + record.PendingUploads.pop_back(); + return; + } + } + + Bool RearmPipeTextureLevelUpload(MG_Pipe::MGPipeHandle res, Uint16 packedTarget, Uint16 level, + const MG_Pipe::MGPBox& wholeLevel) { + // THE SERVER RE-DIRTYING THE CLIENT'S MODEL IS THE ONE DIRECTION D-D5's INVERSION DOES + // NOT COVER, and this is the server-side answer to it (esprytobj review M-1). + // + // D-D5 moves the rect model and the emission cursor to the CLIENT, which clears its own + // flags at emission - so on the handle arm IsStorageDirty is already false for every + // level the applier accepted, and the upload loop reads the applier's pending set + // instead. But three sites in this backend still write into the frontend dirty model, + // and the pending set cannot see them: + // + // Managers.cpp RequireImageBindableStorage - re-dirties every defined level so the + // widened image carrier is replayed rather than allocated empty (LIVE, + // and it is this helper's only caller); + // DirectGLES.cpp:7406 GenerateThreeChannelFloatMipmapOnCpu - same shape, milder + // outcome (package E's file; E's verification round calls this helper + // there, or states why not); + // DirectGLES.cpp:6735 the CLEAR half (MarkStorageDirty(..., false)) after a + // glGetTexImage shadow refresh - it clears rather than dirties, so + // nothing is owed and there is nothing to re-arm. + // + // The alternative the review offered - OR the frontend flag back into the per-level + // question - was NOT taken: it would put two authorities back on the handle arm, and + // "the server never clears the client's flag" (D-D5) would then have to grow an + // exception for the levels the OR consumed. Re-arming the set keeps ONE authority; the + // consume path is unchanged and clears exactly what it uploaded. + // + // The entry is WHOLE-LEVEL and carries no regions (RegionCount 0 = "the union box is the + // whole story", D-D3), because a replay owes every texel of the level, and it MERGES + // with an entry the client already emitted rather than duplicating it - a whole-level + // box subsumes any scatter behind it. Returns false, loudly, when the set is at its + // bound: a dropped replay is the bug this exists to stop, so it must not be silent. + auto& applier = MG_Pipe::MGPipeApplier(); + if (MG_Pipe::MGPipeHandleIsNull(res) || res.Slot >= applier.TextureResources.size()) return false; + auto& record = applier.TextureResources[res.Slot]; + if (!record.Live || record.Gen != res.Gen) return false; + const Uint8 uploadHalf = MG_Pipe::MGPipeSubDataUploadTargetOf(packedTarget); + for (auto& pending : record.PendingUploads) { + if (MG_Pipe::MGPipeSubDataUploadTargetOf(pending.UploadTarget) != uploadHalf || + pending.Level != level) { + continue; + } + pending.UnionBox = wholeLevel; + pending.Regions.clear(); + return true; + } + if (record.PendingUploads.size() >= MG_Pipe::kMGPipeMaxPendingUploads) { + MGLOG_E_ONCE("MGPipe: texture handle {%u, %u} already holds the applier's %u pending " + "uploads, so the server's own re-dirty of (uploadTarget=%u, level=%u) " + "cannot be armed - that level will not be replayed", + res.Slot, res.Gen, MG_Pipe::kMGPipeMaxPendingUploads, uploadHalf, level); + return false; + } + MG_Pipe::MGPipeResourceRecord::PendingUpload entry; + entry.UploadTarget = packedTarget; + entry.Level = level; + entry.UnionBox = wholeLevel; + record.PendingUploads.push_back(std::move(entry)); + return true; + } + + namespace SamplerViewImpl { + BackendSamplerViewTable g_backendSamplerViews; + + BackendSamplerViewObject* GetOrCreateSamplerViewForHandle(MG_Pipe::MGPipeHandle view) { + if (MG_Pipe::MGPipeHandleIsNull(view)) return nullptr; + // The table refuses both of these itself; this is the release-build VOICE for the + // refusal, because MOBILEGL_ASSERT compiles out at INFO and a view that silently + // stops being twinned is the failure mode the refusal exists to replace. Same shape + // as GetOrCreateBufferResourceForHandle's. + if (view.Slot >= BackendSamplerViewTable::kMaxHandleSlot) { + MGLOG_E_ONCE("MGPipe: sampler-view handle slot %u is past the backend table's %u " + "bound - refusing to twin it", + view.Slot, BackendSamplerViewTable::kMaxHandleSlot); + return nullptr; + } + const Uint32 liveGen = g_backendSamplerViews.LiveGenAt(view.Slot); + if (liveGen != 0 && liveGen > view.Gen) { + MGLOG_E_ONCE("MGPipe: sampler-view handle {%u, %u} names a generation BEHIND the " + "live twin's %u - refusing rather than dropping the incumbent", + view.Slot, view.Gen, liveGen); + return nullptr; + } + auto& twin = g_backendSamplerViews.GetOrCreate(view); + if (!twin) twin = MakeShared(); + return twin.get(); + } - void UnpackRingOnPresent() { RingOnPresent(g_unpackRing); } + BackendSamplerViewObject* FindSamplerViewForHandle(MG_Pipe::MGPipeHandle view) { + auto* twin = g_backendSamplerViews.FindByHandle(view); + return twin ? twin->get() : nullptr; + } - void UploadRingOnPresent() { RingOnPresent(g_uploadRing); } - } // namespace BufferImpl + MG_Pipe::MGPipeHandle HandleOfSamplerViewForTexture( + const MG_State::GLState::ITextureObject* textureObject) { + // Through the table's own single-entry front memo rather than straight into + // MGPipeSlots().FindByLifetimeId, which is a hash lookup. HandleOf answers + // identically - the same allocator probe on a miss - and remembers the answer; a + // null answer is deliberately never memoised (SlotTables.h, at HandleOf). +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named debt inside + // the scope - P3b/P4b rekeys the sampler-view registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + return g_backendSamplerViews.HandleOf(textureObject); + } + } // namespace SamplerViewImpl +#endif namespace VertexArrayImpl { namespace { @@ -2200,8 +4843,10 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glVertexBindingDivisor != nullptr; } +#if MOBILEGL_PIPE_LEGACY_MEMOS // Draw state, not VAO state: set by the baseInstance draw entry points around - // PrepareForDraw and back to zero as soon as the draw is issued. + // PrepareForDraw and back to zero as soon as the draw is issued. The legacy arm's + // carrier; the handle arm's is MGPipeApplierState::VertexFetchBaseInstance (D-H2). Uint32 g_pendingFetchBaseInstance = 0; void SetPendingFetchBaseInstance(Uint32 baseInstance) { @@ -2211,6 +4856,11 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 GetPendingFetchBaseInstance() { return g_pendingFetchBaseInstance; } +#endif + +#if MOBILEGL_PIPE_PUSH + Bool BackendUsesNativeBaseInstance() { return g_GLESCapabilities.SupportsBaseInstance; } +#endif // The "+ baseInstance" of GL's instanced-array element index, expressed as a byte shift // of the array's own offset. Only divisor'd arrays step per instance, so only they move. @@ -2263,11 +4913,162 @@ namespace MobileGL::MG_Backend::DirectGLES { return true; } +#if MOBILEGL_PIPE_PUSH + // ---- the handle arm's small helpers ------------------------------------------- + // + // Each one is its object-shaped counterpart above with the frontend reads replaced by + // the two wire views, and nothing else. Divisor is deliberately NOT on the attribute + // view: it is resolved per binding point and rides in MGPVertexBuffer::Divisor, which + // is where glVertexAttribDivisor reads it (D-G2). + + // The entry of the applied set that feeds ATTRIBUTE `attributeIndex`. + // + // THE KEY IS THE ATTRIBUTE INDEX, NOT MGPVertexAttribWire::BindingIndex, and the two + // live in different spaces. Espryt consumes RESOLVED attributes, so the client emits + // set_vertex_buffers as one entry per attribute slot with + // MGPVertexBuffer::BindingIndex == the attribute index (VertexInputEmit.h:190-193, + // :229 - "Espryt consumes RESOLVED attributes, so the set is one entry per attribute + // slot with BindingIndex == the attribute index"), each carrying that attribute's + // already-folded buffer, stride and divisor. MGPVertexAttribWire::BindingIndex is the + // OTHER thing: the GL binding POINT the attribute was attached to by + // glVertexAttribBinding, which is the key of the binding-point view this arm never + // reads (it needs no separate view, because the resolution already happened on the + // client). The two coincide whenever the attribute was configured through + // glVertexAttribPointer, which is why every scenario that uses the pointer API stayed + // green while glVertexAttribBinding(2, 3) / (0, 5) fetched the wrong entry or none - + // KHR-GL43.vertex_attrib_binding's whole subject. + const MG_Pipe::MGPVertexBuffer* VertexBufferForAttributeIndex(const MG_Pipe::MGPipeApplierState& st, + Uint32 attributeIndex) { + if (st.VertexBufferCount == 0) return nullptr; + const Uint32 begin = st.VertexBufferStart; + const Uint32 end = begin + st.VertexBufferCount; + if (attributeIndex >= begin && attributeIndex < end && + attributeIndex < MG_Pipe::kMGPipeMaxVertexAttribs && + st.VertexBuffers[attributeIndex].BindingIndex == attributeIndex) { + return &st.VertexBuffers[attributeIndex]; + } + for (Uint32 i = begin; i < end && i < MG_Pipe::kMGPipeMaxVertexAttribs; ++i) { + if (st.VertexBuffers[i].BindingIndex == attributeIndex) return &st.VertexBuffers[i]; + } + return nullptr; + } + + // BaseInstanceByteShift, on the wire views. baseInstance is added to the ELEMENT index, + // so the divisor does not appear here; a resolved stride of zero never advances and the + // arithmetic already yields zero for it. + inline SizeT BaseInstanceByteShiftWire(Int32 stride, Uint32 divisor, Uint32 baseInstance) { + if (baseInstance == 0 || divisor == 0) return 0; + return static_cast(baseInstance) * static_cast(stride); + } + + // BindAttributeBuffer, resolving the driver id from the slot table instead of from the + // attribute's SharedPtr. It does NOT ensure storage: on this arm the draw's buffers + // were ensured by SyncNeccessaryBuffers earlier in the same PrepareForDraw, which is + // the one place that still holds the frontend objects (a pull site P4b/P8 own). + inline Bool BindAttributeBufferByHandle(MG_Pipe::MGPipeHandle res) { + if (MG_Pipe::MGPipeHandleIsNull(res)) { + MGLOG_W_ONCE("Attribute has no bound buffer, skipping."); + return false; + } + auto* backendResource = BufferImpl::FindBufferResourceForHandle(res); + if (!backendResource || backendResource->id == 0) { + MGLOG_E_ONCE("No backend buffer found for attribute's buffer, cannot bind attribute."); + return false; + } + BufferImpl::BindBufferId(GL_ARRAY_BUFFER, backendResource->id); + return true; + } + + // SyncZeroStrideAttribute on the wire views: the one spelling that can carry a resolved + // stride of zero, which glVertexAttribPointer's zero means the opposite of. + inline Bool SyncZeroStrideAttributeByHandle(Uint attribIndex, const MGPVertexAttribWire& attrib, + const MG_Pipe::MGPVertexBuffer& binding) { + if (MG_Pipe::MGPipeHandleIsNull(binding.Res)) { + MGLOG_W_ONCE("Zero-stride attribute %u has no bound buffer, skipping.", attribIndex); + return false; + } + auto* backendResource = BufferImpl::FindBufferResourceForHandle(binding.Res); + if (!backendResource || backendResource->id == 0) { + MGLOG_E_ONCE("No backend buffer for zero-stride attribute %u, cannot bind it.", attribIndex); + return false; + } + if (!attrib.IsInteger) { + const GLint glSize = attrib.IsBgra ? static_cast(GL_BGRA) : static_cast(attrib.Size); + g_GLESFuncs.glVertexAttribFormat(attribIndex, glSize, + MG_Util::ConvertDataTypeToGLEnum(static_cast(attrib.Type)), + attrib.Normalized ? GL_TRUE : GL_FALSE, 0); + } else { + g_GLESFuncs.glVertexAttribIFormat(attribIndex, static_cast(attrib.Size), + MG_Util::ConvertDataTypeToGLEnum(static_cast(attrib.Type)), + 0); + } + g_GLESFuncs.glVertexAttribBinding(attribIndex, attribIndex); + // The resolved offset goes on the binding point, not into a relative offset (the + // relative offset is capped by GL_MAX_VERTEX_ATTRIB_RELATIVE_OFFSET, a buffer + // offset is not). BindBufferId is bypassed deliberately. + g_GLESFuncs.glBindVertexBuffer(attribIndex, backendResource->id, + static_cast(attrib.Offset), 0); + return true; + } + + // The record the applier holds for the bound vertex-elements CSO, or null. + const MG_Pipe::MGPipeVertexElementsRecord* BoundVertexElementsRecord( + const MG_Pipe::MGPipeApplierState& st) { + const MG_Pipe::MGPipeHandle cso = st.BoundVertexElements; + if (MG_Pipe::MGPipeHandleIsNull(cso)) return nullptr; + if (cso.Slot >= st.VertexElementsCsos.size()) return nullptr; + const auto& record = st.VertexElementsCsos[cso.Slot]; + if (!record.Live || record.Gen != cso.Gen) return nullptr; + return &record; + } + + // P5e (id), CONTRACT-P5E §4.1. See Managers.h for the contract; the body is the one + // SamplerImpl::ResolveSamplerCsoTwin already runs, with the sync left to vi. + BackendVertexArrayObject* ResolveVaoTwin(MG_Pipe::MGPipeHandle elements) { + if (MG_Pipe::MGPipeHandleIsNull(elements)) return nullptr; + // THE RECORD FIRST, before the table is touched: a handle with no record means the + // client named a vertex-elements CSO it never described (or one it deleted while a + // binding still names it), and adopting a slot for it would leave a twin nothing + // can ever sync. The applier's own Gen check is what turns a stale handle into this + // null rather than into its successor's record. + if (PipeRecordAt(MG_Pipe::MGPipeApplier().VertexElementsCsos, elements) == nullptr) { + MGLOG_E_ONCE("MGPipe: vertex-elements CSO {%u, %u} has no applier record on the handle " + "arm, so no driver VAO can be built for it and the draw keeps what is bound", + elements.Slot, elements.Gen); + return nullptr; + } + auto* slot = AdoptTwinByHandle(g_backendVertexArrayObjects, elements, "vertex-elements CSO"); + if (slot == nullptr) return nullptr; + if (!*slot) *slot = MakeShared(); + return slot->get(); + } +#endif // MOBILEGL_PIPE_PUSH + void BackendVertexArrayObject::SyncToBackend( const SharedPtr& stateVAOObject) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif +#if MOBILEGL_PIPE_PUSH + if (BufferImpl::VertexInputSubsystemEnabled()) { + // Everything this arm needs is in the applier's records; the frontend VAO is + // not read at all, which is the whole point of the conversion. + SyncToBackendFromApplier(); + return; + } +#endif +#if !MOBILEGL_PIPE_LEGACY_MEMOS + // UNREACHABLE as of M-4: VertexInputSubsystemEnabled() resolves the arm at its first + // call, and an armless verdict now stops the process there rather than returning + // false into this branch. Kept, and kept loud, because it is the second lock on the + // same question: this is what an arm resolution that ever stopped stopping would + // reach, and drawing on through an unconfigured driver VAO is exactly what must not + // happen quietly. + (void)stateVAOObject; + MGLOG_E_ONCE("MGPipe: the vertex-input subsystem bit is clear and MOBILEGL_PIPE_LEGACY_MEMOS=0 " + "removed the pre-handle VAO sync, so this configuration has no arm at all"); + return; +#else if (!stateVAOObject) { MGLOG_E_ONCE("State VAO object is null, cannot sync to backend."); return; @@ -2493,7 +5294,238 @@ namespace MobileGL::MG_Backend::DirectGLES { m_syncedFetchBaseInstance = fetchBaseInstance; } m_syncedBufferIdGeneration = currentBufferIdGeneration; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS + } + +#if MOBILEGL_PIPE_PUSH + // The same function, driven by the applier's records (D-G4). Every branch of the walk + // survives - the enable/disable block, the fp64 narrowing with its Adreno disable, the + // zero-stride binding-API path, the attribute-buffer bind, the BGRA refusal probe, the + // pointer/IPointer at the shifted fetch offset and the divisor - and the gate is + // re-keyed onto three server-owned MGGen counters plus the CSO's {slot, gen}: + // + // m_syncedElementsHandle + m_syncedElementsSerial <- config version + the whole + // per-attribute version array + // m_syncedVertexBuffersSerial <- (new) the buffer set's own + // m_syncedIndexSerial <- wrapping Uint16 + identity patch + // m_syncedBufferIdGeneration <- UNCHANGED, server-local, and + // still the only thing that + // catches a driver-id re-mint no + // counter on either side moves + // m_hasConvertedFloat64Attribute <- UNCHANGED: a narrowed stream is + // derived from buffer CONTENT, + // which no serial covers + // m_syncedFetchBaseInstance <- no longer in the gate as a + // DIRTY input of its own (a base + // instance change is a + // ContentHash input and so moves + // VertexBuffersSerial), but kept + // as the record of what was last + // EMITTED, which is what the next + // sync has to correct + void BackendVertexArrayObject::SyncToBackendFromApplier() { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + const MG_Pipe::MGPipeApplierState& st = MG_Pipe::MGPipeApplier(); + const auto* rec = BoundVertexElementsRecord(st); + if (rec == nullptr) { + MGLOG_E_ONCE("MGPipe: no vertex-elements record is bound, so the driver VAO cannot be " + "configured - nothing emitted create_vertex_elements/bind_vertex_elements"); + return; + } + + const Uint64 currentBufferIdGeneration = BufferImpl::g_bufferBackendIdGeneration; + const Bool bufferIdsRemitted = m_syncedBufferIdGeneration != currentBufferIdGeneration; + // THIS GATE DEPENDS ON A SERIAL RULE THAT LIVES IN ANOTHER PACKAGE, and it is named + // here because nothing else in this file would say it: MGPipeApplierReset() runs on + // EVERY change of the current GLContext (MG_Impl/Pipe/Tracker.h's `if (m_context != + // &ctx) Reset();`, not only on a fresh one), while this twin SURVIVES the excursion - + // BackendVertexArrayObject has no context-generation member and + // OnBackendContextDestroyed runs on destroy, not on make-current. A reset that sent + // VertexBuffersSerial and IndexBufferSerial back to ZERO would therefore walk them + // back through values this twin has already stamped, and a memo could read clean over + // state the applier had just cleared. MG_Pipe/PipeApply.cpp's reset must ADVANCE + // those two serials instead (wire's C2), which is what makes + // m_syncedVertexBuffersSerial below unable to match after a reset - and that in turn + // forces the whole AND dirty, which is also what rescues the per-record ContentSerial + // half (an applier reset is not a slot recycle, so a re-created CSO at the same + // {slot, gen} restarts its ContentSerial at 1). If that rule is ever reverted, this + // gate is unsafe on any application that changes contexts. + const Bool attributesDirty = bufferIdsRemitted || !m_hasSyncedElements || + !(m_syncedElementsHandle == st.BoundVertexElements) || + m_syncedElementsSerial != rec->ContentSerial || + m_syncedVertexBuffersSerial != st.VertexBuffersSerial; + const Bool indexBufferDirty = bufferIdsRemitted || m_syncedIndexSerial != st.IndexBufferSerial; + // Emulation is server-owned: the client sends the draw's RAW base instance and never + // learns the answer. Applied here as well as in the applier so the decision is the + // same whichever side resolved it first - it is idempotent. + const Uint32 fetchBaseInstance = + BackendUsesNativeBaseInstance() ? 0u : st.VertexFetchBaseInstance; + const Bool baseInstanceDirty = m_syncedFetchBaseInstance != fetchBaseInstance; + const Bool emitAttributes = attributesDirty || baseInstanceDirty || m_hasConvertedFloat64Attribute; + if (!emitAttributes && !indexBufferDirty) { + return; + } + m_hasConvertedFloat64Attribute = false; + + Bind(); + + // ALL 32 SLOTS, not rec->AttributeCount, and the difference is the disable arm. + // The legacy walk ran over the frontend's whole 32-slot attribute array and reached + // glDisableVertexAttribArray for every attribute that was off; a walk bounded by the + // record's count leaves an attribute the configuration has just DROPPED enabled in + // the driver VAO, pointing at whatever buffer it last held - which is the class the + // first Adreno workaround below exists to prevent. Nothing in the contract requires + // the client to emit 32 entries (D-H3 sets the precedent the other way for the + // BUFFER set: "truncated to the highest enabled attribute + 1"), so the bound was a + // requirement on B that was neither stated nor pinned. It costs nothing to drop it: + // MGPipeApplyCreateVertexElements zeroes both arrays before it unpacks, so every + // entry past AttributeCount reads Enabled = 0, Type = 0 (Int8, not Float64) and + // IsLong = 0 - i.e. exactly "disable this array", with no dependency on B at all. + for (Uint attribIndex = 0; attribIndex < MG_Pipe::kMGPipeMaxVertexAttribs && emitAttributes; + ++attribIndex) { + const MGPVertexAttribWire& attrib = rec->Attributes[attribIndex]; + const MG_Pipe::MGPVertexBuffer* binding = + VertexBufferForAttributeIndex(st, attribIndex); + const Uint32 divisor = binding != nullptr ? binding->Divisor : 0u; + + // The enable/disable block. On this arm there is no per-attribute version to + // compare: the applier's Attributes[] IS what was last pushed, so a moved + // ContentSerial means re-emit and an unchanged one means the early-out above + // already returned. + if (attrib.Enabled) { + g_GLESFuncs.glEnableVertexAttribArray(attribIndex); + } else { + g_GLESFuncs.glDisableVertexAttribArray(attribIndex); + } + + // The fp64 narrowing, verbatim in behaviour including the Adreno workaround: + // when no float32 stream can be built the array is DISABLED rather than left + // enabled with no pointer, which is what the driver turns into a SIGSEGV inside + // the next draw (KHR-GL43.vertex_attrib_binding.basic-input-case4). IsLong is + // carried separately from Type == Float64 on the wire precisely so this test + // can still tell the two apart. + if (attrib.IsLong || attrib.Type == static_cast(DataType::Float64)) { + if (attrib.Enabled && attrib.Type == static_cast(DataType::Float64) && + binding != nullptr && + SyncFloat64AttributeAsFloat32ByHandle(attribIndex, attrib, *binding, fetchBaseInstance)) { + m_hasConvertedFloat64Attribute = true; + // Explicit, not redundant: an earlier walk that could not build the + // stream disabled this array. + g_GLESFuncs.glEnableVertexAttribArray(attribIndex); + g_GLESFuncs.glVertexAttribDivisor(attribIndex, divisor); + continue; + } + if (attrib.Enabled) { + MGLOG_W_ONCE("DirectGLES: vertex attribute %u is a 64-bit (GL_DOUBLE) array whose source " + "stream could not be narrowed to float32 - disabling the array", + attribIndex); + } + g_GLESFuncs.glDisableVertexAttribArray(attribIndex); + continue; + } + + if (!attrib.Enabled || binding == nullptr) continue; + + // A resolved stride of zero is the binding model's "never advance" and + // glVertexAttribPointer cannot say it - its zero means "tightly packed", i.e. + // the opposite. ES 3.1's binding-point API can. + if (attrib.Stride == 0 && HasVertexBindingApi()) { + if (!SyncZeroStrideAttributeByHandle(attribIndex, attrib, *binding)) { + continue; + } + // No shift here on purpose: a zero stride never advances. + g_GLESFuncs.glVertexBindingDivisor(attribIndex, divisor); + continue; + } + + if (!BindAttributeBufferByHandle(binding->Res)) { + continue; + } + + // GL_BGRA as a vertex SIZE is desktop-only and ES rejects it, which leaves the + // array ENABLED with no pointer - and the Adreno driver then dereferences null + // inside the next draw (KHR-GL43.vertex_attrib_binding.basic-input-case5). So + // the refusal is observed and the array disabled. Deliberately ONLY this + // format: the per-draw sync must not grow a glGetError round trip for the + // formats real applications use. + const Bool formatMayBeRefused = attrib.IsBgra != 0; + if (formatMayBeRefused) { + while (g_GLESFuncs.glGetError() != GL_NO_ERROR) { + } // start from a clean slate so the check below is about THIS call + } + + const SizeT fetchOffset = static_cast(attrib.Offset) + + BaseInstanceByteShiftWire(attrib.Stride, divisor, fetchBaseInstance); + const GLenum glType = MG_Util::ConvertDataTypeToGLEnum(static_cast(attrib.Type)); + + if (!attrib.IsInteger) { + const GLint glSize = attrib.IsBgra ? static_cast(GL_BGRA) : static_cast(attrib.Size); + g_GLESFuncs.glVertexAttribPointer(attribIndex, glSize, glType, + attrib.Normalized ? GL_TRUE : GL_FALSE, attrib.Stride, + (const void*)fetchOffset); + } else { + g_GLESFuncs.glVertexAttribIPointer(attribIndex, static_cast(attrib.Size), glType, + attrib.Stride, (const void*)fetchOffset); + } + + if (formatMayBeRefused && g_GLESFuncs.glGetError() != GL_NO_ERROR) { + MGLOG_W_ONCE("DirectGLES: the driver refused the vertex format of attribute %u " + "(size=%d bgra=%d type=%s) - disabling the array so the draw cannot " + "fetch through a pointer the driver never accepted", + attribIndex, static_cast(attrib.Size), attrib.IsBgra ? 1 : 0, + MG_Util::ConvertGLEnumToString(glType).c_str()); + g_GLESFuncs.glDisableVertexAttribArray(attribIndex); + continue; + } + + g_GLESFuncs.glVertexAttribDivisor(attribIndex, divisor); + } + + if (indexBufferDirty) { + Bool indexBufferSynced = false; + if (!MG_Pipe::MGPipeHandleIsNull(st.IndexBuffer.Res)) { + // RESOLVE ONLY, no ensure - unlike the legacy arm, which called + // EnsureBufferResource here. PrepareForDraw runs SyncNeccessaryBuffers before + // SyncCurrentVAO, so an INDEXED draw has already ensured this store; a + // non-indexed draw whose IndexBufferSerial moved has not, and then this logs + // once and leaves the binding alone. That is noisy rather than wrong: nothing + // is stamped on the miss (indexBufferSynced stays false), so the next indexed + // draw repairs it. Ensuring here would need the frontend object this arm + // deliberately does not hold. + auto* backendResource = BufferImpl::FindBufferResourceForHandle(st.IndexBuffer.Res); + if (backendResource && backendResource->id != 0) { + BufferImpl::BindBufferId(GL_ELEMENT_ARRAY_BUFFER, backendResource->id); + indexBufferSynced = true; + } else { + MGLOG_W_ONCE("No backend buffer found for index buffer binding, cannot bind index buffer."); + } + } else { + g_GLESFuncs.glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + indexBufferSynced = true; + } + if (indexBufferSynced) { + // The two element-array restore scopes (the restart substitution's and + // MultiDrawImpl's) put the DRIVER id back without touching this serial, + // which is correct: the serial records what the APPLIER last said, and + // those scopes restored exactly what the applier said. + m_syncedIndexSerial = st.IndexBufferSerial; + } + } + + if (attributesDirty) { + m_syncedElementsHandle = st.BoundVertexElements; + m_syncedElementsSerial = rec->ContentSerial; + m_syncedVertexBuffersSerial = st.VertexBuffersSerial; + m_hasSyncedElements = true; + } + if (emitAttributes) { + m_syncedFetchBaseInstance = fetchBaseInstance; + } + m_syncedBufferIdGeneration = currentBufferIdGeneration; } +#endif // MOBILEGL_PIPE_PUSH void BackendVertexArrayObject::SyncClientSideAttributesForDrawArrays( const SharedPtr& stateVAOObject, GLint first, GLsizei count) { @@ -2548,6 +5580,10 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast(converted.size() * sizeof(Float)), converted.data(), GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(converted.size() * sizeof(Float))); + } // GL ignores `normalized` for floating-point array types, so it is not // forwarded here either. g_GLESFuncs.glVertexAttribPointer(attribIndex, attrib.Size, GL_FLOAT, GL_FALSE, @@ -2578,6 +5614,10 @@ namespace MobileGL::MG_Backend::DirectGLES { BufferImpl::BindBufferId(GL_ARRAY_BUFFER, bufferId); g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast(uploadSize), clientData, GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(uploadSize)); + } if (!attrib.IsInteger) { const GLint glSize = attrib.IsBgra ? static_cast(GL_BGRA) : attrib.Size; @@ -2593,50 +5633,200 @@ namespace MobileGL::MG_Backend::DirectGLES { } - Bool BackendVertexArrayObject::SyncFloat64AttributeAsFloat32( - Uint attribIndex, const MG_State::GLState::VertexAttribute& attrib, Uint32 fetchBaseInstance) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + Bool BackendVertexArrayObject::SyncFloat64AttributeAsFloat32( + Uint attribIndex, const MG_State::GLState::VertexAttribute& attrib, Uint32 fetchBaseInstance) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + if (attribIndex >= m_convertedAttributeBufferIds.size() || attrib.Size < 1 || attrib.Size > 4) { + return false; + } + const auto& bufferObject = attrib.Buffer; + if (!bufferObject) { + // A client-memory 64-bit array is narrowed on the draw path instead, which is the + // only place its fetch range is known (SyncClientSideAttributesForDrawArrays). + return false; + } + + // The frontend shadow is what the conversion reads, so a shader write that has not + // been pulled back yet has to land first. A no-op unless one is outstanding. + bufferObject->SyncGpuWrites(); + const Uint8* const sourceBase = bufferObject->MappedData(); + const SizeT sourceSize = bufferObject->GetSize(); + if (sourceBase == nullptr || attrib.Offset >= sourceSize) { + return false; + } + + const SizeT componentCount = static_cast(attrib.Size); + const SizeT sourceElementSize = componentCount * sizeof(Double); + const SizeT available = sourceSize - attrib.Offset; + if (available < sourceElementSize) { + return false; + } + + // A resolved stride of zero is the binding model's "never advance" (see + // VertexAttribute::Stride): exactly one element exists and every vertex reads it, so + // exactly one is converted. Otherwise the array's extent is the source buffer's own - + // SyncToBackend has no draw range, and a whole-array conversion is affordable because + // it is memoised on the buffer's change serial and 64-bit arrays are vanishingly rare. + const Bool neverAdvances = attrib.Stride <= 0; + const SizeT sourceStride = neverAdvances ? sourceElementSize : static_cast(attrib.Stride); + const SizeT elementCount = neverAdvances ? 1 : ((available - sourceElementSize) / sourceStride) + 1; + + // baseInstance shifts the ELEMENT index of a divisor'd array, and one element of the + // converted stream is componentCount floats. A zero stride never advances, so no + // shift can move it. A shift past the array's own extent has no source data at all. + const SizeT firstElement = (fetchBaseInstance != 0 && attrib.Divisor != 0 && !neverAdvances) + ? static_cast(fetchBaseInstance) + : 0; + if (firstElement >= elementCount) { + return false; + } + + auto& stream = m_convertedAttributeStreams[attribIndex]; + Uint& convertedBufferId = m_convertedAttributeBufferIds[attribIndex]; + const Uint64 sourceLifetimeId = bufferObject->GetLifetimeId(); + const Uint64 sourceChangeSerial = bufferObject->GetChangeSerial(); + // A persistent map is written through the pointer, with no API call to bump the change + // serial (see BufferObject::SyncPersistentMappedRange), so its serial cannot prove the + // converted copy is still current and the memo is never trusted for one. + const Bool memoHit = + stream.valid && convertedBufferId != 0 && !bufferObject->IsBackendPersistentMapped() && + stream.sourceLifetimeId == sourceLifetimeId && stream.sourceChangeSerial == sourceChangeSerial && + stream.sourceOffset == attrib.Offset && stream.sourceStride == sourceStride && + stream.componentCount == componentCount && stream.elementCount == elementCount; + if (!memoHit) { + if (convertedBufferId == 0) { + g_GLESFuncs.glGenBuffers(1, &convertedBufferId); + if (convertedBufferId == 0) { + MGLOG_E_ONCE("Failed to create the float32 scratch buffer for the 64-bit vertex array at " + "attribute %u.", + attribIndex); + return false; + } + } + Vector converted; + NarrowDoubleStreamToFloat32(sourceBase + attrib.Offset, sourceStride, componentCount, elementCount, + converted); + BufferImpl::BindBufferId(GL_ARRAY_BUFFER, convertedBufferId); + g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, + static_cast(converted.size() * sizeof(Float)), + converted.data(), GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + // The VBO-backed half of the 64-bit narrowing. Same population as the + // client-array half above: a stream the backend synthesises per draw. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(converted.size() * sizeof(Float))); + } + stream.valid = true; + stream.sourceLifetimeId = sourceLifetimeId; + stream.sourceChangeSerial = sourceChangeSerial; + stream.sourceOffset = attrib.Offset; + stream.sourceStride = sourceStride; + stream.componentCount = componentCount; + stream.elementCount = elementCount; + MGLOG_D("DirectGLES: narrowed the 64-bit vertex array at attribute %u to %zu float32 element(s).", + attribIndex, elementCount); + } + + const SizeT convertedElementSize = componentCount * sizeof(Float); + if (neverAdvances) { + // Only the binding-point API can say "stride 0": glVertexAttribPointer's zero + // means "tightly packed" instead, i.e. the opposite, and would walk the driver + // straight off the end of the single converted element. + if (!HasVertexBindingApi()) { + return false; + } + g_GLESFuncs.glVertexAttribFormat(attribIndex, attrib.Size, GL_FLOAT, GL_FALSE, 0); + g_GLESFuncs.glVertexAttribBinding(attribIndex, attribIndex); + g_GLESFuncs.glBindVertexBuffer(attribIndex, convertedBufferId, 0, 0); + return true; + } + + BufferImpl::BindBufferId(GL_ARRAY_BUFFER, convertedBufferId); + // `normalized` is deliberately GL_FALSE rather than attrib.Normalized: GL ignores it + // for floating-point array types, and honouring it would scale the fetched values + // (KHR-GL43.vertex_attrib_binding.basic-input-case5 passes GL_TRUE and expects 10/20). + g_GLESFuncs.glVertexAttribPointer(attribIndex, attrib.Size, GL_FLOAT, GL_FALSE, + static_cast(convertedElementSize), + (const void*)(firstElement * convertedElementSize)); + return true; + } +#endif // MOBILEGL_PIPE_LEGACY_MEMOS + +#if MOBILEGL_PIPE_PUSH + Bool BackendVertexArrayObject::SyncFloat64AttributeAsFloat32ByHandle( + Uint attribIndex, const MGPVertexAttribWire& attrib, const MG_Pipe::MGPVertexBuffer& binding, + Uint32 fetchBaseInstance) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (attribIndex >= m_convertedAttributeBufferIds.size() || attrib.Size < 1 || attrib.Size > 4) { return false; } - const auto& bufferObject = attrib.Buffer; - if (!bufferObject) { - // A client-memory 64-bit array is narrowed on the draw path instead, which is the - // only place its fetch range is known (SyncClientSideAttributesForDrawArrays). + if (MG_Pipe::MGPipeHandleIsNull(binding.Res)) { + // A client-memory 64-bit array is narrowed on the draw path instead, which is + // the only place its fetch range is known. return false; } + auto* resource = BufferImpl::FindBufferResourceForHandle(binding.Res); + if (resource == nullptr) return false; - // The frontend shadow is what the conversion reads, so a shader write that has not - // been pulled back yet has to land first. A no-op unless one is outstanding. - bufferObject->SyncGpuWrites(); - const Uint8* const sourceBase = bufferObject->MappedData(); - const SizeT sourceSize = bufferObject->GetSize(); + // WHAT IS NOT HERE, AND IT IS A RULED DEVIATION FOR P3a RATHER THAN AN OVERSIGHT + // (M-3; integrator ruling, this commit). + // + // The legacy arm opens with bufferObject->SyncGpuWrites(), one of the eleven Espryt + // SyncPersistentMappedRange / SyncGpuWrites sites D-N keeps where they are for P3a. + // D-N's wording is "no MOVE of those sites off the frontend", and this arm does not + // move it: it CANNOT MAKE IT AT ALL. The call needs a frontend BufferObject and this + // path holds only a handle, because the server has no inverse map back to a frontend + // object - that absence is the design, not a gap in it (ARCHITECTURE.md 4.2). The + // two ways to keep the site here would each break something D-N or D-J protects: a + // handle -> object map is the very thing the split removes, and pulling the bytes + // eagerly on the client at every draw is new behaviour and new cost. + // + // SO THE DEVIATION IS DECLARED, WITH ITS BLAST RADIUS. Under the default mask a + // 64-bit vertex array whose SOURCE buffer was written by a shader and not yet pulled + // back narrows STALE bytes on this arm and fresh bytes on the legacy one. That is + // the whole of it: fp64 vertex arrays, fed by a buffer a shader wrote, read without + // any intervening explicit readback. No other attribute type reads through this + // path, and a persistently mapped source is excluded separately below (the memo + // never trusts one, so those re-read every draw through the coherent map). + // + // P8 closes it by moving the pull to the client, where the object lives. Until then + // this is the ONE behavioural difference between the two arms under the default + // mask, and it is written here rather than only in a document so that the next + // reader of this function finds it at the site. + // WHERE THE BYTES ARE, and it is not one fixed place: an ADOPTED store has no client + // shadow left at all (PipeResource::AdoptPersistentMap clears and shrinks it), so + // the coherent map IS the source of truth - which is exactly the case the memo + // exclusion below makes the most frequent, since a persistently mapped source is + // never trusted and this narrowing therefore re-reads on every draw. Reading the + // recorded shadow base for such a resource read freed memory; hostBytes is nulled + // at adoption for the same reason, so the fallback below is a null and a refusal + // rather than a stale read. + const Uint8* const sourceBase = + (resource->persistentMapped && resource->persistentPtr != nullptr) + ? static_cast(resource->persistentPtr) + : resource->hostBytes; + const SizeT sourceSize = BufferImpl::ResourceWidthForHandle(binding.Res); if (sourceBase == nullptr || attrib.Offset >= sourceSize) { return false; } const SizeT componentCount = static_cast(attrib.Size); const SizeT sourceElementSize = componentCount * sizeof(Double); - const SizeT available = sourceSize - attrib.Offset; + const SizeT available = sourceSize - static_cast(attrib.Offset); if (available < sourceElementSize) { return false; } - // A resolved stride of zero is the binding model's "never advance" (see - // VertexAttribute::Stride): exactly one element exists and every vertex reads it, so - // exactly one is converted. Otherwise the array's extent is the source buffer's own - - // SyncToBackend has no draw range, and a whole-array conversion is affordable because - // it is memoised on the buffer's change serial and 64-bit arrays are vanishingly rare. const Bool neverAdvances = attrib.Stride <= 0; const SizeT sourceStride = neverAdvances ? sourceElementSize : static_cast(attrib.Stride); const SizeT elementCount = neverAdvances ? 1 : ((available - sourceElementSize) / sourceStride) + 1; - // baseInstance shifts the ELEMENT index of a divisor'd array, and one element of the - // converted stream is componentCount floats. A zero stride never advances, so no - // shift can move it. A shift past the array's own extent has no source data at all. - const SizeT firstElement = (fetchBaseInstance != 0 && attrib.Divisor != 0 && !neverAdvances) + const SizeT firstElement = (fetchBaseInstance != 0 && binding.Divisor != 0 && !neverAdvances) ? static_cast(fetchBaseInstance) : 0; if (firstElement >= elementCount) { @@ -2645,16 +5835,18 @@ namespace MobileGL::MG_Backend::DirectGLES { auto& stream = m_convertedAttributeStreams[attribIndex]; Uint& convertedBufferId = m_convertedAttributeBufferIds[attribIndex]; - const Uint64 sourceLifetimeId = bufferObject->GetLifetimeId(); - const Uint64 sourceChangeSerial = bufferObject->GetChangeSerial(); - // A persistent map is written through the pointer, with no API call to bump the change - // serial (see BufferObject::SyncPersistentMappedRange), so its serial cannot prove the - // converted copy is still current and the memo is never trusted for one. - const Bool memoHit = - stream.valid && convertedBufferId != 0 && !bufferObject->IsBackendPersistentMapped() && - stream.sourceLifetimeId == sourceLifetimeId && stream.sourceChangeSerial == sourceChangeSerial && - stream.sourceOffset == attrib.Offset && stream.sourceStride == sourceStride && - stream.componentCount == componentCount && stream.elementCount == elementCount; + const Uint64 sourceSerial = BufferImpl::ResourceSerialForHandle(binding.Res); + // The memo, re-keyed: the pin is the source buffer's {slot, gen} instead of a + // frontend lifetime id, and the freshness input is the applier's server-owned + // Serial instead of the frontend change serial. A PERSISTENTLY MAPPED source is + // still never trusted - it is written through the pointer with no call at all, so + // no serial on either side can prove the converted copy is current. + const Bool memoHit = stream.valid && convertedBufferId != 0 && !resource->persistentMapped && + stream.sourceHandle == binding.Res && + stream.sourceChangeSerial == sourceSerial && + stream.sourceOffset == static_cast(attrib.Offset) && + stream.sourceStride == sourceStride && + stream.componentCount == componentCount && stream.elementCount == elementCount; if (!memoHit) { if (convertedBufferId == 0) { g_GLESFuncs.glGenBuffers(1, &convertedBufferId); @@ -2669,13 +5861,16 @@ namespace MobileGL::MG_Backend::DirectGLES { NarrowDoubleStreamToFloat32(sourceBase + attrib.Offset, sourceStride, componentCount, elementCount, converted); BufferImpl::BindBufferId(GL_ARRAY_BUFFER, convertedBufferId); - g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, - static_cast(converted.size() * sizeof(Float)), + g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast(converted.size() * sizeof(Float)), converted.data(), GL_STREAM_DRAW); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(converted.size() * sizeof(Float))); + } stream.valid = true; - stream.sourceLifetimeId = sourceLifetimeId; - stream.sourceChangeSerial = sourceChangeSerial; - stream.sourceOffset = attrib.Offset; + stream.sourceHandle = binding.Res; + stream.sourceChangeSerial = sourceSerial; + stream.sourceOffset = static_cast(attrib.Offset); stream.sourceStride = sourceStride; stream.componentCount = componentCount; stream.elementCount = elementCount; @@ -2685,13 +5880,11 @@ namespace MobileGL::MG_Backend::DirectGLES { const SizeT convertedElementSize = componentCount * sizeof(Float); if (neverAdvances) { - // Only the binding-point API can say "stride 0": glVertexAttribPointer's zero - // means "tightly packed" instead, i.e. the opposite, and would walk the driver - // straight off the end of the single converted element. + // Only the binding-point API can say "stride 0". if (!HasVertexBindingApi()) { return false; } - g_GLESFuncs.glVertexAttribFormat(attribIndex, attrib.Size, GL_FLOAT, GL_FALSE, 0); + g_GLESFuncs.glVertexAttribFormat(attribIndex, static_cast(attrib.Size), GL_FLOAT, GL_FALSE, 0); g_GLESFuncs.glVertexAttribBinding(attribIndex, attribIndex); g_GLESFuncs.glBindVertexBuffer(attribIndex, convertedBufferId, 0, 0); return true; @@ -2699,15 +5892,15 @@ namespace MobileGL::MG_Backend::DirectGLES { BufferImpl::BindBufferId(GL_ARRAY_BUFFER, convertedBufferId); // `normalized` is deliberately GL_FALSE rather than attrib.Normalized: GL ignores it - // for floating-point array types, and honouring it would scale the fetched values - // (KHR-GL43.vertex_attrib_binding.basic-input-case5 passes GL_TRUE and expects 10/20). - g_GLESFuncs.glVertexAttribPointer(attribIndex, attrib.Size, GL_FLOAT, GL_FALSE, + // for floating-point array types, and honouring it would scale the fetched values. + g_GLESFuncs.glVertexAttribPointer(attribIndex, static_cast(attrib.Size), GL_FLOAT, GL_FALSE, static_cast(convertedElementSize), (const void*)(firstElement * convertedElementSize)); return true; } +#endif // MOBILEGL_PIPE_PUSH - StateBackendObjectRegistry + TwinRegistry g_backendVertexArrayObjects; } // namespace VertexArrayImpl @@ -2786,11 +5979,71 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_backendTextureId; } +#if MOBILEGL_PIPE_PUSH + // P5e (tx2), CONTRACT-P5E §5.2 / scout G-S2-5. See the declaration. + void BackendTextureObject::RequireImageBindableStorageByHandle( + MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPipeResourceRecord& record) { + if (m_imageBindableStorageRequired) { + return; + } +#if MOBILEGL_BUILD_DISAGGREGATED + // THE REFUSAL, AT THE ENTRY. The predicate is the SAME one the frontend overload + // hoisted to ahead of its first guarded contact - "would any level actually be + // replayed", answered from the server's own staged-texture store - so this changes + // WHERE the abort happens and not WHETHER it happens. What it buys is that the + // reason can still be stated: three frames deeper it was a MarkStorageDirty guard + // naming "texture-legacy-arm", which says nothing about image bindability. + // + // A texture with nothing defined yet is NOT refused: it is allocated image-bindable + // up front and pulls nothing, which is exactly what ImageBindableHint exists to make + // the common case. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + auto& store = MG_Remote::Server::ServerStagedTexture(); + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForHandle(res); + Bool anyLevelWouldReplay = false; + for (const auto& uploadTarget : + BufferImpl::StagedUploadTargetsForPipeTarget(record.Desc.Target)) { + for (Uint32 level = 0; level < record.Desc.Levels; ++level) { + if (store.IsLevelDefined(key, static_cast(uploadTarget), + static_cast(level))) { + anyLevelWouldReplay = true; + break; + } + } + if (anyLevelWouldReplay) break; + } + if (anyLevelWouldReplay) { + MG_Pipe::MGPipeUnmigratedEmulation("image-bindable-redirty"); + } + } +#else + (void)res; + (void)record; +#endif + // Nothing to replay, so the transition is just the two sticky flags plus the + // parameter resync the widening's swizzle needs - the whole of the frontend + // overload's first half, with its second half refused above. + m_imageBindableStorageRequired = true; + m_isInitialized = false; + // The widened carrier's swizzle override, exactly as the frontend overload's tail + // sets it and for the same reason: the parameter sync is gated on a params version + // this transition does not move. + m_forceTextureParamsResync = true; + } +#endif + void BackendTextureObject::RequireImageBindableStorage( const SharedPtr& stateTextureObject) { if (m_imageBindableStorageRequired) { return; } +#if MOBILEGL_PIPE_PUSH + // Whether this transition re-mints storage that ALREADY EXISTED on the backend: that + // is the remint PULL (the levels below are replayed from the client's shadow to fill + // the new carrier), and it is what ROADMAP open question 2 counts. A texture reaching + // here uninitialised is allocated image-bindable up front and pulls nothing. + const Bool hadBackendStorage = m_isInitialized; +#endif m_imageBindableStorageRequired = true; m_isInitialized = false; // Every level this object has ALREADY uploaded has to be replayed, because the @@ -2803,6 +6056,97 @@ namespace MobileGL::MG_Backend::DirectGLES { // the image loads are wrong. Reached whenever anything syncs the texture first - a // glGetTexImage, a draw that samples it, an FBO attach - which is why it survived so // long: the scenario that binds the image immediately after uploading never sees it. + // + // P4a (D-M): THIS RE-DIRTY IS AN EMULATION THAT CANNOT SURVIVE A SPLIT, and it is + // the head of ARCHITECTURE.md:299-308's one new stall class. The server is reaching + // BACK into the client's own dirty model to ask for texels it does not hold; under + // split there is no client address space to reach into and the levels have to be + // PULLED across the reverse channel. P4a supplies mitigation 1 - the prevention + // half, MGPResourceDesc::ImageBindableHint on every create and respecify, so a + // texture that has ever been image-bound is allocated in the carrier from the start + // and never reaches this path - and NAMES the site. Mitigations 2-4 (the async pull, + // the bounded retention and the ResourceSubDataComplete terminator) and + // TextureRemintPullScenario are P9's, and P4a must not build half a terminator. + // + // In monolith the code below keeps running exactly as it does today: the Fatal is a + // split-only arm and the monolith body of MGPipeUnmigratedEmulation is a no-op. + // + // THE MARKER IS RAISED WHERE A LEVEL IS ACTUALLY REPLAYED, not on entry (review + // N-4). ImageBindableHint IS the prevention half of mitigation 1, and D's own + // metadata-respecify arm makes the hint's ARRIVAL the trigger for entering this + // function - so a glBindImageTexture on a texture whose storage is not yet defined + // reaches here, finds no defined level, replays nothing, and would nevertheless have + // counted (and, under split, aborted at) the very emulation the hint exists to + // prevent. What the marker is about is stated in its own comment above: reaching + // BACK into the client's dirty model for texels. No texels owed, nothing to mark. +#if MOBILEGL_PIPE_PUSH + // P4a (review M-1): ON THE HANDLE ARM THE RE-DIRTY HAS TO REACH THE APPLIER'S SET, + // not only the frontend's model. The client cleared its own flags when it emitted + // those levels, so MarkStorageDirty below re-arms a model the handle arm does not + // read: the pending set would stay empty, MGB_LEVEL_NEEDS_UPLOAD would answer false + // for every level, and the widened image carrier this transition schedules would be + // allocated EMPTY - the very bug the paragraph above says "survived so long", + // re-introduced one arm over. The dispatch that triggered the bind would read zeroes. + // + // Both models are written, not one: the frontend's because the legacy arm reads it + // and because the client's own NoteLevelDirty hook rides on it, the applier's + // because that is what this arm consumes. + Bool markedRemintPull = false; + const MG_Pipe::MGPipeHandle rearmRes = + TextureResourceSubsystemEnabled() + ? [&]() { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed resolution, named + // debt inside the scope - P3b/P4b rekeys the registry. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + return g_backendTextureObjects.HandleOf(stateTextureObject.get()); + }() + : MG_Pipe::kMGPipeNullHandle; + const Uint32 rearmResourceTarget = + MG_Pipe::MGPipeResourceTargetForTextureTarget(stateTextureObject->GetTarget()); + if (TextureResourceSubsystemEnabled() && MG_Pipe::MGPipeHandleIsNull(rearmRes)) { + MGLOG_E_ONCE("MGPipe: texture %u needs image-bindable storage but has no handle on the " + "handle arm, so the levels it owes cannot be re-armed in the applier's " + "pending set - they would be allocated empty", + stateTextureObject->GetExternalIndex()); + } +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt): under an active transport the replay loop below reaches the layer-1 + // texture guard (MarkStorageDirty on a frontend object is Fatal{RoleViolation, + // "texture-legacy-arm"}) BEFORE the N-4 marker it owns - so the marker is hoisted + // here, ahead of the first guarded contact, with its semantics unchanged: raised + // only when a level would actually be replayed. The predicate is answered by the + // server's own staged-texture store rather than the frontend's extent walk: a + // Defined level has a non-zero extent and bytes on the frontend (the two conditions + // the loop tests), and an undefined one reads {0,0,0} and is skipped. A texture with + // no applier record (rearmRes null, the MGLOG_E_ONCE arm above) falls through to the + // guard, which names the same violation one level down. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Pipe::MGPipeHandleIsNull(rearmRes)) { + const auto* rearmRecord = PipeTextureRecordForHandle(rearmRes); + if (rearmRecord != nullptr) { + auto& rearmStore = MG_Remote::Server::ServerStagedTexture(); + const Uint64 rearmKey = MG_Remote::Server::StagedTextureStore::KeyForHandle(rearmRes); + Bool anyLevelWouldReplay = false; + for (const auto& uploadTarget : + BufferImpl::StagedUploadTargetsForPipeTarget(rearmRecord->Desc.Target)) { + for (Uint32 level = 0; level < rearmRecord->Desc.Levels; ++level) { + if (rearmStore.IsLevelDefined(rearmKey, static_cast(uploadTarget), + static_cast(level))) { + anyLevelWouldReplay = true; + break; + } + } + if (anyLevelWouldReplay) break; + } + if (anyLevelWouldReplay) { + MG_Pipe::MGPipeUnmigratedEmulation("texture-remint-pull"); + } + } + } +#endif if (auto* mipmapObject = MG_State::GLState::AsMipmapTexture(stateTextureObject.get())) { const auto levelCount = mipmapObject->GetMipmapLevelCount(); for (const auto& uploadTarget : stateTextureObject->GetUploadTargets()) { @@ -2811,6 +6155,32 @@ namespace MobileGL::MG_Backend::DirectGLES { if (levelTexelSize.x() <= 0 || levelTexelSize.y() <= 0) continue; if (mipmapObject->GetMipmapByteSize(uploadTarget, level) == 0) continue; mipmapObject->MarkStorageDirty(uploadTarget, level, true); +#if MOBILEGL_PIPE_PUSH + // N-4: one level owed is what makes this an unmigrated emulation. Once + // per transition, not once per level: the marker names the SITE. + if (!markedRemintPull) { + MG_Pipe::MGPipeUnmigratedEmulation("texture-remint-pull"); + markedRemintPull = true; + // THE COUNTER BEHIND ROADMAP OPEN QUESTION 2 (final review M-A): one per + // transition that replays a level of storage the backend already held. + if (hadBackendStorage && MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureRemintPulls, 1); + } + } + if (!MG_Pipe::MGPipeHandleIsNull(rearmRes)) { + const MG_Pipe::MGPBox wholeLevel{0, + 0, + 0, + static_cast(levelTexelSize.x()), + static_cast(levelTexelSize.y()), + static_cast(std::max(levelTexelSize.z(), 1))}; + RearmPipeTextureLevelUpload( + rearmRes, + MG_Pipe::MGPipePackSubDataTarget(rearmResourceTarget, + static_cast(uploadTarget)), + static_cast(level), wholeLevel); + } +#endif } } } @@ -3590,6 +6960,102 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } +#if MOBILEGL_PIPE_PUSH + // P5e (tx2): the GL name a log line wants, from whichever side of the arm can answer it. + // Desc.GlNameForDiag is the declared diagnostics carrier (MGPipeTypes.h) and it is the + // ONLY thing the by-handle arm may say about a texture's frontend name - a GL name is + // never an identity, never a memo key and never part of a hash (section 4.2.1). The + // twenty-odd GetExternalIndex() log reads this family carried (scout G-S2-4) all become + // this call. + static Uint32 TextureDiagName(const SharedPtr& stateTextureObject, + const MG_Pipe::MGPipeResourceRecord* record) { + if (stateTextureObject) return static_cast(stateTextureObject->GetExternalIndex()); + return record != nullptr ? record->Desc.GlNameForDiag : 0u; + } +// True while this twin has NO noted handle, i.e. it is the monolith-glue half and the frontend +// object below is the only thing that can answer. A macro and not a member function because the +// pull build has no such member at all and this has to fold to a constant there (D-P: the pull +// build's preprocessed text may not gain a call). +#define MGB_TEXTURE_HANDLE_ARM_OFF(twin) (MG_Pipe::MGPipeHandleIsNull((twin).PushedSyncHandle())) + + // P5e (fix1, ID-81 / CONTRACT-P5E §5.8): THE NULL FRONTEND OBJECT IS A CONTRACT AND THIS + // IS WHERE IT IS CHECKED, ONCE, FOR ALL THREE SYNC BODIES. + // + // tx2's three sync bodies accept a null `stateTextureObject` because the RECORD answers + // every read they make - but only where the record arm is actually SELECTED. Two reads + // inside SyncMipmapsToBackend are gated on `Transport != Monolith` (the texture-VIEW test + // and RequireImageBindableStorage's re-dirty transition, §5.2), and every read in all + // three is gated on TextureResourceSubsystemEnabled() through `pushedStorage`. So a null + // object is servable exactly when BOTH hold, and the by-handle entries beside them + // (SyncTextureToBackendByHandle / SyncMipmapsToBackendByHandle) pass null on the strength + // of a NOTED HANDLE, which says nothing about either. + // + // The device found the gap: with MOBILEGL_TRANSPORT=monolith the push build still drives + // its framebuffer attachments from the record (P4a, D-C2), fb's SyncAttachmentSurface + // called the by-handle storage sync from that arm, and `stateTextureObject->IsTextureView()` + // dereferenced the null SharedPtr in Lightmap. -> clearColorTexture -> glClear. + // The arm selection is repaired at that call site; this refusal is what makes the shape + // UNREPEATABLE rather than merely repaired - a null frontend object reaching a body that + // can still read one aborts BY NAME instead of taking a SIGSEGV three frames deep. + // +// P5e (fix1 follow-up, ID-110): THE RECORD ARM'S SELECTOR, STATED ONCE AND SPELLED WHEREVER IT +// IS RELIED ON. fix1's root cause was a seam that read "a handle was noted" as "the record arm +// is selected"; those are different statements and this is the second one. Everything a null +// frontend texture object is servable by lives behind BOTH conjuncts: the transport (the +// texture-VIEW test and RequireImageBindableStorage's re-dirty transition are +// `Transport != Monolith` arms, CONTRACT-P5E §5.2) and the subsystem bit (every `pushedStorage` +// read). Sites that were previously correct only because every writer of a handle-note happens +// to be transport-gated now say so themselves, so a future by-handle noter cannot quietly +// re-open ID-107's hole somewhere else. +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGB_TEXTURE_RECORD_ARM_SELECTED() \ + (MG_Config::Transport != MG_Config::TransportMode::Monolith && TextureResourceSubsystemEnabled()) +#else +#define MGB_TEXTURE_RECORD_ARM_SELECTED() (false) +#endif + + // Returns true when the caller must decline (no handle noted: the caller's bug it always + // was, named by the caller's own log line), false when the record arm can serve the call, + // and never returns at all when a handle was noted on an arm that cannot serve it. + static Bool RefuseNullFrontendTextureOffTheHandleArm(const char* entry, + MG_Pipe::MGPipeHandle notedHandle) { + if (MG_Pipe::MGPipeHandleIsNull(notedHandle)) return true; + if (MGB_TEXTURE_RECORD_ARM_SELECTED()) return false; + MGLOG_F("MGPipe: Fatal{RoleViolation, \"texture-handle-arm\"} - %s was handed a NULL " + "frontend texture for handle {%u, %u} on an arm that cannot answer without " + "one. The by-handle entries pass null on the strength of a noted handle, and " + "the record arm they rely on is selected by Transport != Monolith AND the " + "texture-resource subsystem bit (CONTRACT-P5E §5.8, ID-81); neither holds " + "here, so the body below would read a frontend object that does not exist", + entry, notedHandle.Slot, notedHandle.Gen); + std::abort(); + } +// The guard the three sync bodies spell. In the PULL build it folds to the constant the +// pre-fix1 text folded to, so that build's preprocessed text gains no call (D-P). +#define MGB_TEXTURE_NULL_FRONTEND_REFUSED(twin, entry) \ + (RefuseNullFrontendTextureOffTheHandleArm((entry), (twin).PushedSyncHandle())) +// P5e (tx2), CONTRACT-P5E §5.2: THE TEXTURE'S TARGET, from the descriptor whenever a record was +// resolved. The two live `GetTarget()` reads left INSIDE the handle arm (scout G-S2-2, the +// parameter sync and the built-in sampler sync) read Desc.Target here, through the same +// StagedTextureTargetForPipeTarget mapping the storage sync already routes through - so all +// three bodies answer the question from one carrier rather than two authorities. +#define MGB_TEXPARAM_TARGET(rec, obj) \ + ((rec) != nullptr ? BufferImpl::StagedTextureTargetForPipeTarget((rec)->Desc.Target) : (obj)->GetTarget()) +// The ~20 GetExternalIndex() reads this family makes in LOG LINES (scout G-S2-4). The pull +// expansion is the pre-P5e expression with one pair of parentheses around the receiver. +#define MGB_TEXTURE_DIAG_NAME(rec, obj) (TextureDiagName((obj), (rec))) +// The storage KIND, from the descriptor whenever one was resolved - the backstop arm of the +// storage switch used to ask the frontend object for a value it had just read from the record. +#define MGB_TEXTURE_STORAGE_KIND(rec, obj) \ + ((rec) != nullptr ? static_cast((rec)->Desc.StorageKind) : (obj)->GetStorageType()) +#else +#define MGB_TEXTURE_HANDLE_ARM_OFF(twin) (true) +#define MGB_TEXTURE_NULL_FRONTEND_REFUSED(twin, entry) (true) +#define MGB_TEXPARAM_TARGET(rec, obj) ((obj)->GetTarget()) +#define MGB_TEXTURE_DIAG_NAME(rec, obj) ((obj)->GetExternalIndex()) +#define MGB_TEXTURE_STORAGE_KIND(rec, obj) ((obj)->GetStorageType()) +#endif + // The ES entry point for EXT/OES_texture_view, whichever spelling this driver brought. // Callers must have checked g_GLESCapabilities.SupportsTextureView first - the capability // is the extension AND the pointer, because eglGetProcAddress hands back live-looking @@ -3605,9 +7071,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // that needs no work costs the same nothing per draw that any other synced texture does. void BackendTextureObject::StampViewSyncKeys( const SharedPtr& stateTextureObject) { - if (MG_State::pGLContext) { - m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId(); - m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + if (MGB_CTX_LIVE) { + m_syncedShapeContextId = MGB_CTX->GetTextureContextId(); + m_syncedShapeGeneration = MGB_CTX->GetSamplingResolutionGeneration(); m_syncedShapeParamsVersion = stateTextureObject->GetTextureParamsVersion(); } m_syncedContentVersion = stateTextureObject->GetContentVersion(); @@ -3706,13 +7172,308 @@ namespace MobileGL::MG_Backend::DirectGLES { StampViewSyncKeys(stateTextureObject); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2): see the declaration for the split between what the record answers and what + // the wire still cannot say about a view. + void BackendTextureObject::SyncTextureViewToBackendByRecord( + MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPipeResourceRecord& record, + const SharedPtr& stateTextureObject) { + if (!g_GLESCapabilities.SupportsTextureView) { + // Unreachable through the API - the frontend refuses glTextureView without + // GL_ARB_texture_view - and kept for the reason the frontend arm keeps its twin. + MGLOG_E_ONCE("Texture view %u reached the backend on a driver without " + "EXT/OES_texture_view.", + record.Desc.GlNameForDiag); + return; + } + // The storage owner by handle, through the same by-handle entry every other draw-path + // texture takes: no frontend object, no allocator probe. + const SharedPtr storageBackendObject = + SyncTextureToBackendByHandle(record.Desc.ViewOf, m_imageBindableStorageRequired); + if (!storageBackendObject) { + MGLOG_E_ONCE("Failed to sync the storage texture {%u, %u} of view %u on the handle arm.", + record.Desc.ViewOf.Slot, record.Desc.ViewOf.Gen, record.Desc.GlNameForDiag); + return; + } + const Uint storageBackendTextureId = storageBackendObject->GetBackendTextureId(); + if (storageBackendTextureId == 0) { + MGLOG_D("Storage texture of view %u has no ES name yet.", record.Desc.GlNameForDiag); + return; + } + if (m_isInitialized && m_viewSourceBackendTextureId == storageBackendTextureId) { + // The steady case, and the only one this arm has to be fast at: one record read, + // one integer compare and the serial stamp that keeps the aggregate clean gate + // (IsDrawSyncCleanByRecord) answering true for the next draw. + m_syncedResourceSerial = record.Serial; + return; + } + // TRAILING (tx2 report, "left barriered"): the glTextureView CALL needs the view + // window and MGPResourceDesc has no carrier for it. The frontend object is taken when + // the monolith-glue note still reaches one - which is every case a single-process + // split has - and the refusal is NAMED rather than silent for the case that does not. + if (stateTextureObject) { + SyncTextureViewToBackend(stateTextureObject); + m_syncedResourceSerial = record.Serial; + return; + } + MGLOG_E_ONCE("MGPipe: texture view {%u, %u} (GL %u) has to be (re-)created as a view of " + "{%u, %u}, and MGPResourceDesc carries no view window - minLevel, " + "numLevels, minLayer, numLayers have no wire field - so the by-handle arm " + "cannot issue glTextureView. P5e leaves view CREATION on the frontend arm; " + "this name will sample empty until it gets one.", + res.Slot, res.Gen, record.Desc.GlNameForDiag, record.Desc.ViewOf.Slot, + record.Desc.ViewOf.Gen); + } +#endif + + // P4a (D-D5), the dirty-ownership inversion, applied at the two places the upload loops + // below actually ask: "does this (uploadTarget, level) owe an upload" and "it has been + // uploaded". ARCHITECTURE.md:253 puts the rect model and the emission cursor on the + // CLIENT, which clears its own flags AT EMISSION - so on the handle arm the frontend's + // IsStorageDirty is already false for every level the applier accepted, and asking it + // would upload nothing at all. The applier's pending set is what these two read instead; + // it is server-side state, it survives this function's bail arms (an incomplete texture + // returns early, a multisample target refreshes and skips), and an entry is consumed + // ONLY where the level actually uploaded. + // + // THE UPLOAD-TARGET ENCODING is static_cast(TextureUploadTarget), which is what + // package B must emit into MGPSubData::Target's cube-face half; it is recorded as a + // deviation because the contract left it unstated. + // + // MACROS AND NOT LAMBDAS, and that is a G1 requirement rather than a style choice: + // SyncMipmapsToBackend already holds nine ErrorLopper lambdas whose mangled names are + // ...::$_N BY POSITION, so inserting two more renumbered every one of them - measured as + // eight symbols added, eight removed and four std::function thunks resized in the PULL + // build, whose symbol set P4a may not move by one byte (D-P). A macro expands to the + // pre-P4a expression exactly when MOBILEGL_PIPE_PUSH is off, so the pull build's + // preprocessed text, and therefore its object code, is unchanged. Both are #undef'd + // immediately after the function. +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (tx): THE READS THE FOUR UPLOAD ARMS MAKE, re-sourced. With an active transport the +// apply thread may not name the client's TextureObjectMipmap at all (rule E), so on the +// handle arm: +// +// * the level TEXELS come from the server's staged-texture store, adopted at apply time +// (MGB_LEVEL_TEXELS - Fatal{StageSnapshotTooNarrow} when no record covered the level, +// which is the data-correctness refusal of the texture half); +// * the per-level EXTENT and DEFINED-NESS come from the same store (fed by the sub-data +// adoption and the respecify hook; {0,0,0} for a level nothing defined, which is exactly +// GetMipmapTexelSize's answer for one); +// * the level BYTE SIZE is the adopted run's length; +// * the texture TARGET and the UPLOAD-TARGET list come from the descriptor. +// +// Every macro keeps the P4a discipline: the non-disaggregated expansion is the original +// frontend read, character for character modulo one pair of parentheses, so the pull and +// push builds compile exactly what they compiled before tx, and every disaggregated arm +// falls back to the frontend read when there is no active transport (the legacy arm, and +// monolith). MGB_STAGED_TEXTURE_LIVE is the runtime discriminator; pushedStorage/pushedRes +// are the function's own locals. All are #undef'd with the rest after the function. +#define MGB_STAGED_TEXTURE_LIVE \ + (pushedStorage != nullptr && MG_Remote::Server::ServerStagedTexture().CopiesIntoServerStorage()) +#define MGB_TEXTURE_TARGET(obj) \ + (MGB_STAGED_TEXTURE_LIVE ? BufferImpl::StagedTextureTargetForPipeTarget(pushedStorage->Desc.Target) \ + : (obj)->GetTarget()) +#define MGB_UPLOAD_TARGETS(obj) \ + (MGB_STAGED_TEXTURE_LIVE ? BufferImpl::StagedUploadTargetsForPipeTarget(pushedStorage->Desc.Target) \ + : (obj)->GetUploadTargets()) +#define MGB_LEVEL_TEXEL_SIZE(obj, tgt, lvl) \ + (MGB_STAGED_TEXTURE_LIVE \ + ? MG_Remote::Server::ServerStagedTexture().LevelExtentOrUndefined( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl)) \ + : (obj)->GetMipmapTexelSize(tgt, lvl)) +#define MGB_LEVEL_BYTE_SIZE(obj, tgt, lvl) \ + (MGB_STAGED_TEXTURE_LIVE \ + ? MG_Remote::Server::ServerStagedTexture().LevelByteSize( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl)) \ + : (obj)->GetMipmapByteSize(tgt, lvl)) +#define MGB_LEVEL_TEXELS(obj, tgt, lvl, site) \ + (MGB_STAGED_TEXTURE_LIVE \ + ? MG_Remote::Server::ServerStagedTexture().RequireLevelBytes( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl), site) \ + : (obj)->MapMipmapData(tgt, lvl)) +#else +#define MGB_TEXTURE_TARGET(obj) ((obj)->GetTarget()) +#define MGB_UPLOAD_TARGETS(obj) ((obj)->GetUploadTargets()) +#define MGB_LEVEL_TEXEL_SIZE(obj, tgt, lvl) ((obj)->GetMipmapTexelSize(tgt, lvl)) +#define MGB_LEVEL_BYTE_SIZE(obj, tgt, lvl) ((obj)->GetMipmapByteSize(tgt, lvl)) +#define MGB_LEVEL_TEXELS(obj, tgt, lvl, site) ((obj)->MapMipmapData(tgt, lvl)) +#endif + +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (tx): the disaggregated pair adds ONE term and ONE clear to the P4a shapes - the +// staged-texture store's GPU-dirty mark (T5: a level the GPU generated dirties the SERVER's +// shadow, and the pending set cannot see it). The mark is never set on Espryt today - its +// GenerateMipmap fills the levels on the driver - so the term is inert here and is the +// contract-shaped answer (§2.2's last row) rather than a hot-path cost: one m_any acquire +// load when the store is empty. +#define MGB_LEVEL_NEEDS_UPLOAD(obj, tgt, lvl) \ + (pushedStorage != nullptr \ + ? (FindPipeTextureUpload(*pushedStorage, static_cast(tgt), static_cast(lvl)) != nullptr || \ + MG_Remote::Server::ServerStagedTexture().IsLevelGpuDirty( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl))) \ + : (obj)->IsStorageDirty(tgt, lvl)) +// Re-resolves the record itself, so it is safe after any amount of driver work - and it +// invalidates any PendingUpload* taken earlier for THIS texture, which is why every such pointer +// is used and dropped inside one level's iteration. +#define MGB_LEVEL_UPLOAD_DONE(obj, tgt, lvl) \ + do { \ + if (pushedStorage != nullptr) { \ + ConsumePipeTextureUpload(pushedRes, static_cast(tgt), static_cast(lvl)); \ + if (MGB_STAGED_TEXTURE_LIVE) { \ + MG_Remote::Server::ServerStagedTexture().MarkLevelGpuDirty( \ + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), static_cast(tgt), \ + static_cast(lvl), false); \ + } \ + } else { \ + (obj)->MarkStorageDirty(tgt, lvl, false); \ + } \ + } while (0) +#elif MOBILEGL_PIPE_PUSH +#define MGB_LEVEL_NEEDS_UPLOAD(obj, tgt, lvl) \ + (pushedStorage != nullptr \ + ? FindPipeTextureUpload(*pushedStorage, static_cast(tgt), static_cast(lvl)) != nullptr \ + : (obj)->IsStorageDirty(tgt, lvl)) +// Re-resolves the record itself, so it is safe after any amount of driver work - and it +// invalidates any PendingUpload* taken earlier for THIS texture, which is why every such pointer +// is used and dropped inside one level's iteration. +#define MGB_LEVEL_UPLOAD_DONE(obj, tgt, lvl) \ + do { \ + if (pushedStorage != nullptr) { \ + ConsumePipeTextureUpload(pushedRes, static_cast(tgt), static_cast(lvl)); \ + } else { \ + (obj)->MarkStorageDirty(tgt, lvl, false); \ + } \ + } while (0) +#else +#define MGB_LEVEL_NEEDS_UPLOAD(obj, tgt, lvl) ((obj)->IsStorageDirty(tgt, lvl)) +#define MGB_LEVEL_UPLOAD_DONE(obj, tgt, lvl) (obj)->MarkStorageDirty(tgt, lvl, false) +#endif + + // THE STORAGE SHAPE, and on the handle arm it is the DESCRIPTOR's (C.3's d2: "the + // m_prevTextureInfo probe re-keyed onto the resource record's Serial AND Desc"; review + // M-2). v1 moved the parameters and the uploads and left the allocation reading the + // frontend object, which put TWO AUTHORITIES on one value inside one twin - + // SyncTextureParamsToBackend already answers MGB_TEXPARAM_FORMAT from + // Desc.InternalFormat while this function allocated from GetFormat(). In monolith they + // agree, so no lane can expose the split; under split the descriptor is all there is. + // + // WHAT THE DESCRIPTOR ANSWERS: internal format, base extent, level count, sample count, + // fixed sample locations, immutability, storage kind and the buffer-texture window. What + // it does NOT answer, and what still comes from the frontend shadow, is the PER-LEVEL + // extent and the level BYTES - MGPResourceDesc carries one base extent, the per-level + // sizes are derived from it, and the texels themselves ride on sub-data. A level the + // descriptor's Levels claims and the shadow has not defined reads back {0,0,0} and the + // per-level loops skip it, which is the same arm they take for a sparse chain today. + // + // AND Desc.HasDefinedContent IS DELIBERATELY NOT READ HERE (review N-8, and the + // enumeration above exists to be exhaustive, so the omission is stated rather than + // left to be noticed). c0e made it storage-defining and the BUFFER family reads it + // three times, because a buffer's content is ONE fact about the whole resource. A + // texture's is not: content is per (uploadTarget, level) and its authority is the + // pending set (D-D5), which is per level and survives a bail. A whole-resource + // "content is undefined" would say nothing the per-level loops do not already answer + // by finding no level to upload, and acting on it would mean throwing away levels the + // set still owes. The field stays a respecify CLASSIFIER (it moves the descriptor, so + // wire refuses to call such a respecify metadata-only) and nothing more on this arm. + // + // MACROS, NOT LOCALS, FOR THE SAME REASON AS THE PAIR ABOVE (DV-9, and it was measured): + // routing these through locals resized SyncTextureParamsToBackend by +9 bytes in the + // PULL build and P4a's admitted-resize set is EMPTY. Each takes the object it would + // otherwise have been spelled on, so the pull expansion is the pre-P4a expression with + // one pair of parentheses around the receiver. All are #undef'd with the pair above. +#if MOBILEGL_PIPE_PUSH +#define MGB_STORAGE_FORMAT(obj) \ + (pushedStorage != nullptr ? static_cast(pushedStorage->Desc.InternalFormat) \ + : (obj)->GetFormat()) +#define MGB_STORAGE_BASE_SIZE(obj) \ + (pushedStorage != nullptr ? IntVec3{static_cast(pushedStorage->Desc.Width), \ + static_cast(pushedStorage->Desc.Height), \ + static_cast(pushedStorage->Desc.Depth)} \ + : (obj)->GetBaseSize()) +#define MGB_STORAGE_LEVELS(obj) \ + (pushedStorage != nullptr ? static_cast(pushedStorage->Desc.Levels) : (obj)->GetMipmapLevelCount()) +#define MGB_STORAGE_SAMPLES(obj) \ + (pushedStorage != nullptr ? static_cast(pushedStorage->Desc.Samples) : (obj)->GetSamples()) +#define MGB_STORAGE_FIXED_SAMPLE_LOCATIONS(obj) \ + (pushedStorage != nullptr ? pushedStorage->Desc.FixedSampleLocations != 0 : (obj)->HasFixedSampleLocations()) +#define MGB_STORAGE_IMMUTABLE(obj) \ + (pushedStorage != nullptr ? pushedStorage->Desc.Immutable != 0 : (obj)->IsImmutable()) +#define MGB_STORAGE_KIND(obj) \ + (pushedStorage != nullptr ? static_cast(pushedStorage->Desc.StorageKind) \ + : (obj)->GetStorageType()) +#else +#define MGB_STORAGE_FORMAT(obj) ((obj)->GetFormat()) +#define MGB_STORAGE_BASE_SIZE(obj) ((obj)->GetBaseSize()) +#define MGB_STORAGE_LEVELS(obj) ((obj)->GetMipmapLevelCount()) +#define MGB_STORAGE_SAMPLES(obj) ((obj)->GetSamples()) +#define MGB_STORAGE_FIXED_SAMPLE_LOCATIONS(obj) ((obj)->HasFixedSampleLocations()) +#define MGB_STORAGE_IMMUTABLE(obj) ((obj)->IsImmutable()) +#define MGB_STORAGE_KIND(obj) ((obj)->GetStorageType()) +#endif + void BackendTextureObject::SyncMipmapsToBackend( const SharedPtr& stateTextureObject) { - if (!stateTextureObject) { + // P5e (tx2): null IS the by-handle arm once a handle has been noted on this twin; + // see the twin note in SyncBuiltinSamplerToBackend. P5e (fix1): "a handle was noted" + // is not on its own the statement that the record arm will be SELECTED - the view + // test below and RequireImageBindableStorage's transition are transport-gated - so + // the check is the one shared refusal and a noted handle on an arm that cannot serve + // it aborts by name rather than falling into the frontend reads. + if (!stateTextureObject && MGB_TEXTURE_NULL_FRONTEND_REFUSED(*this, "SyncMipmapsToBackend")) { MGLOG_E_ONCE("State texture object is null, cannot sync to backend."); return; } +#if MOBILEGL_PIPE_PUSH + // P4a (D-B3/D-D5): on the handle arm the record IS the state this function reads. + // P5e (tx2) hoists the resolution to the TOP of the function, because the view test + // below needs it before anything else does; nothing else about it moves. + // + // The record pointer stays valid for the whole function: only a create/respecify for + // a NEW slot grows MGPipeApplierState::TextureResources, and nothing this function + // calls emits one. Pointers INTO record->PendingUploads do not - the consume is a + // swap-and-pop - so every one of them is taken, used and dropped inside one level's + // iteration, and never held across a consume. + const MG_Pipe::MGPipeResourceRecord* pushedStorage = nullptr; + MG_Pipe::MGPipeHandle pushedRes = MG_Pipe::kMGPipeNullHandle; + if (TextureResourceSubsystemEnabled()) { + pushedStorage = ResolveOwnRecord(stateTextureObject); + pushedRes = !MG_Pipe::MGPipeHandleIsNull(m_pushedSyncHandle) + ? m_pushedSyncHandle + : (pushedStorage != nullptr ? pushedStorage->Desc.Resource + : MG_Pipe::kMGPipeNullHandle); + if (pushedStorage == nullptr) { + MGLOG_E_ONCE("MGPipe: texture %u has no applier record on the handle arm, so its " + "storage and uploads cannot be driven from the pushed descriptor " + "(handle {%u, %u})", + TextureDiagName(stateTextureObject, pushedStorage), pushedRes.Slot, + pushedRes.Gen); + return; + } + } +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), CONTRACT-P5E §5.2 (scout G-S2-2): THE VIEW TEST MOVES BEHIND THE + // RECORD. `IsTextureView()` was the one frontend read this function made BEFORE it + // had resolved anything at all, and Desc.ViewOf is the carrier - it names the storage + // owner BY HANDLE, so both the question and its answer are server-owned. + // + // Ruling 1's arm: the push-monolith build keeps the frontend test below token for + // token, because there the two answers are the same answer and the verify comparator + // needs the frontend arm. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && pushedStorage != nullptr && + !MG_Pipe::MGPipeHandleIsNull(pushedStorage->Desc.ViewOf)) { + SyncTextureViewToBackendByRecord(pushedRes, *pushedStorage, stateTextureObject); + return; + } + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && pushedStorage != nullptr) { + // Not a view: fall through to the storage body with the frontend test skipped. + } else +#endif // A texture created by glTextureView owns no storage: the levels, the format and // every texel belong to the texture it views, and this name only has to be made to // ALIAS them. Everything below - storage allocation, respecification, per-level @@ -3726,6 +7487,71 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif + // P4a (D-B3/D-D5): on the handle arm the record IS the state this function reads. + // Resolved once, here, because three things below want it - the cheap gate, the + // per-level dirty questions and the union box / rect list / strides the staging + // planner takes its shape from. + // + // The record pointer stays valid for the whole function: only a create/respecify + // for a NEW slot grows MGPipeApplierState::TextureResources, and nothing this + // function calls emits one. Pointers INTO record->PendingUploads do not - the + // consume is a swap-and-pop - so every one of them is taken, used and dropped + // inside one level's iteration, and never held across a consume. +#if MOBILEGL_PIPE_PUSH + if (pushedStorage != nullptr) { + // THE METADATA RESPECIFY, HONOURED (ID-18 M4, review M-2). BindMask and + // ImageBindableHint are STICKY facts the client discovers AFTER allocation - a + // texture first bound as a shader image - and an IMMUTABLE texture never has a + // later redefinition to carry them, so they arrive on a respecify that restates + // the storage the resource already has. The applier classifies such a record as a + // metadata update: no reallocation ack, no pending-upload clear, Serial moved. + // THE TWIN'S HALF IS TO RE-DERIVE ITS STORAGE FLAGS FROM THE NEW MASK AND + // RECREATE ONLY WHERE THE BACKEND NEEDS THE FLAG AT CREATION - and image + // bindability is exactly such a flag on this backend: the carrier may be channel- + // widened, which is a different allocation, not a different binding. + // + // RequireImageBindableStorage IS that path and it is idempotent, so this is the + // whole of the re-derivation: it sets the sticky flag, clears m_isInitialized so + // the storage below is regenerated, re-arms every defined level in BOTH dirty + // models (see its own comment) so the widened carrier is replayed rather than + // allocated empty, and forces the parameter resync the widening's swizzle needs. + // Arriving on the CREATE instead - the common case once B publishes the hint - + // costs nothing: m_isInitialized is already false and there are no levels to + // replay. + const Bool pushedWantsImageBindableStorage = + pushedStorage->Desc.ImageBindableHint != 0 || + (pushedStorage->Desc.BindMask & MG_Pipe::kMGPipeBindShaderImage) != 0; + if (pushedWantsImageBindableStorage && !m_imageBindableStorageRequired) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2), ruling 1's arm: under a transport the re-dirty half is a NAMED + // refusal at its entry (§5.2) instead of a walk of the client's level shadows; + // the push-monolith build keeps the frontend transition exactly as it is. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + RequireImageBindableStorageByHandle(pushedRes, *pushedStorage); + } else +#endif + RequireImageBindableStorage(stateTextureObject); + } + + // ONE compare replaces the whole cheap-gate trio AND the content version. The + // applier bumps Serial on every respecify and every sub-data it applies to this + // resource, which is exactly the union those four keys covered - without the + // coarse behaviour the sampling-resolution generation had, where any texture's + // shape churn re-opened every other texture's gate. + // + // The pending set is the second half and it is not redundant: a previous sync + // may have BAILED (an incomplete texture returns early, a multisample target + // refreshes and skips) with the serial already stamped, and the set is what + // survives that. Empty means every level this record ever declared has been + // consumed by a sync that actually uploaded it. + if (m_isInitialized && m_syncedResourceSerial != 0 && + m_syncedResourceSerial == pushedStorage->Serial && + pushedStorage->PendingUploads.empty()) { + return; + } + } +#endif + // First-level clean gate (see the member comment): three version compares and no // virtual shape walk. Every mutation the slower probe below would catch bumps one of // the keys - shape via the context's sampling-resolution generation (coarse: any @@ -3734,9 +7560,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // version - and backend-side storage resets clear m_isInitialized. Restricted to // Mipmap storage like the probe fast path: a buffer texture's backing store can move // without any of these keys noticing. - if (m_isInitialized && m_syncedShapeContextId != 0 && MG_State::pGLContext && - m_syncedShapeContextId == MG_State::pGLContext->GetTextureContextId() && - m_syncedShapeGeneration == MG_State::pGLContext->GetSamplingResolutionGeneration() && + // + // LEGACY ARM ONLY from P4a on: the memos it reads are the pre-handle ones and the + // record above answers the same question in one compare. +#if MOBILEGL_PIPE_PUSH + if (pushedStorage == nullptr) +#endif + if (m_isInitialized && m_syncedShapeContextId != 0 && MGB_CTX_LIVE && + m_syncedShapeContextId == MGB_CTX->GetTextureContextId() && + m_syncedShapeGeneration == MGB_CTX->GetSamplingResolutionGeneration() && m_syncedContentVersion == stateTextureObject->GetContentVersion() && m_syncedShapeParamsVersion == stateTextureObject->GetTextureParamsVersion() && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap) { @@ -3744,10 +7576,10 @@ namespace MobileGL::MG_Backend::DirectGLES { } MGLOG_D("Syncing texture mipmaps with backend ID %u to backend for state ID %u", m_backendTextureId, - stateTextureObject->GetExternalIndex()); + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject)); - GLenum target = ConvertTextureTargetToBackendGLEnum(stateTextureObject->GetTarget()); - auto targetInternal = stateTextureObject->GetTarget(); + GLenum target = ConvertTextureTargetToBackendGLEnum(MGB_TEXTURE_TARGET(stateTextureObject)); + auto targetInternal = MGB_TEXTURE_TARGET(stateTextureObject); MGLOG_D(" Texture target for syncing is %s", MG_Util::ConvertTextureTargetToString(targetInternal).c_str()); if (!IsSupportedTextureTarget(targetInternal)) { @@ -3768,6 +7600,48 @@ namespace MobileGL::MG_Backend::DirectGLES { // left the backend name with no levels whatsoever, so the level that WAS defined could // never be sampled or read back. Sync whenever some level holds an image; the per-level // loops below skip the degenerate ones individually. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt, CONTRACT-P5C §6 layer 1): on the staged arm the same question is answered + // out of the SERVER's staged-texture store, never out of the frontend object - the + // store's Defined-ness (fed by the respecify hook and the sub-data adoption) IS "some + // level holds an image", and IsComplete()'s sampling half is redundant with it: a + // complete mipmap chain has a defined level 0, and the gate's whole job is to let an + // incomplete-but-partly-defined chain through. The one divergence is deliberate and + // stated: a chain whose EVERY level is 0x0 is complete-by-quirk on the frontend (the + // "0x0 in last level" relaxation, TextureObject.cpp) and undefined here - and syncing + // it would upload nothing either way, because every per-level loop below skips a + // {0,0,0} level individually. A BUFFER texture never has staged levels, so its gate + // is the descriptor's format - exactly what TextureObjectBase::IsComplete() reduces + // to for that storage kind. + if (MGB_STAGED_TEXTURE_LIVE) { + const auto& stagedDesc = pushedStorage->Desc; + Bool anyDefined = false; + if (static_cast(stagedDesc.StorageKind) == TextureStorageType::Buffer) { + // A buffer texture has no staged levels; its gate is the descriptor's + // format, which is what TextureObjectBase::IsComplete() reduces to here. + anyDefined = static_cast(stagedDesc.InternalFormat) != + TextureInternalFormat::Unknown; + } else { + auto& stagedStore = MG_Remote::Server::ServerStagedTexture(); + const Uint64 stagedKey = MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes); + for (const auto& uploadTarget : BufferImpl::StagedUploadTargetsForPipeTarget(stagedDesc.Target)) { + for (Uint32 level = 0; level < stagedDesc.Levels; ++level) { + if (stagedStore.IsLevelDefined(stagedKey, static_cast(uploadTarget), + static_cast(level))) { + anyDefined = true; + break; + } + } + if (anyDefined) break; + } + } + if (!anyDefined) { + MGLOG_D("Texture object with ID: %u has no defined image level, skipping sync.", + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject)); + return; + } + } else +#endif if (!stateTextureObject->IsComplete() && !HasAnyDefinedMipmapLevel(stateTextureObject.get())) { MGLOG_D("Texture object with ID: %u has no defined image level, skipping sync.", stateTextureObject->GetExternalIndex()); @@ -3785,6 +7659,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // unchanged shape means no level can be dirty. Shape stays a separate // compare because a NULL-data glTexImage changes it without touching the // content version. + // + // LEGACY ARM ONLY from P4a on: on the handle arm the record's Serial plus an empty + // pending set is the same statement in one compare, made above and BEFORE the + // IsComplete()/shape walk rather than after it - so this probe would only re-derive + // an answer already given, out of the very frontend state the arm exists to stop + // reading. +#if MOBILEGL_PIPE_PUSH + if (pushedStorage == nullptr) +#endif if (m_isInitialized && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap && m_syncedContentVersion != 0 && m_syncedContentVersion == stateTextureObject->GetContentVersion()) { @@ -3805,9 +7688,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // The probe just proved "fully synced" from the real state, so the cheap // gate may be (re)stamped here: the coarse generation only ever goes stale // from OTHER textures' churn, and this draw re-validated this one. - if (MG_State::pGLContext) { - m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId(); - m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + if (MGB_CTX_LIVE) { + m_syncedShapeContextId = MGB_CTX->GetTextureContextId(); + m_syncedShapeGeneration = MGB_CTX->GetSamplingResolutionGeneration(); m_syncedShapeParamsVersion = stateTextureObject->GetTextureParamsVersion(); } return; @@ -3818,20 +7701,20 @@ namespace MobileGL::MG_Backend::DirectGLES { DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__](GLenum err) { MGLOG_D("%s(%s:%d) ES error: %s", func, file, line, MG_Util::ConvertGLEnumToString(err).c_str()); }); - const auto baseSize = stateTextureObject->GetBaseSize(); - StateTextureBasicInfo currentTextureInfo = {stateTextureObject->GetFormat(), + const auto baseSize = MGB_STORAGE_BASE_SIZE(stateTextureObject); + StateTextureBasicInfo currentTextureInfo = {MGB_STORAGE_FORMAT(stateTextureObject), static_cast(baseSize.x()), static_cast(baseSize.y()), static_cast(baseSize.z()), 0, 0, - stateTextureObject->GetSamples(), - stateTextureObject->HasFixedSampleLocations()}; - switch (stateTextureObject->GetStorageType()) { + MGB_STORAGE_SAMPLES(stateTextureObject), + MGB_STORAGE_FIXED_SAMPLE_LOCATIONS(stateTextureObject)}; + switch (MGB_STORAGE_KIND(stateTextureObject)) { case TextureStorageType::Mipmap: { auto* textureMipmapObject = static_cast(stateTextureObject.get()); - const auto mipmapCount = textureMipmapObject->GetMipmapLevelCount(); + const auto mipmapCount = MGB_STORAGE_LEVELS(textureMipmapObject); currentTextureInfo.mipmapLevels = mipmapCount; Bool needsRegeneration = !m_isInitialized || (currentTextureInfo != m_prevTextureInfo); @@ -3846,13 +7729,13 @@ namespace MobileGL::MG_Backend::DirectGLES { // TextureImpl::GetImageBindableStorageWidening for what widens and why. const TextureImpl::ImageBindableStorageWidening imageWidening = m_imageBindableStorageRequired - ? TextureImpl::GetImageBindableStorageWidening(textureMipmapObject->GetFormat()) + ? TextureImpl::GetImageBindableStorageWidening(MGB_STORAGE_FORMAT(textureMipmapObject)) : TextureImpl::ImageBindableStorageWidening{}; const Bool canAppendMipmaps = m_isInitialized && !m_imageBindableStorageRequired && - !stateTextureObject->IsImmutable() && + !MGB_STORAGE_IMMUTABLE(stateTextureObject) && currentTextureInfo.internalFormat == m_prevTextureInfo.internalFormat && currentTextureInfo.width == m_prevTextureInfo.width && currentTextureInfo.height == m_prevTextureInfo.height && @@ -3865,48 +7748,49 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("%s: Got texture info: %dx%dx%d, mips %d, format %s", __func__, baseSize.x(), baseSize.y(), baseSize.z(), mipmapCount, - MG_Util::ConvertTextureInternalFormatToString(textureMipmapObject->GetFormat()).c_str()); + MG_Util::ConvertTextureInternalFormatToString(MGB_STORAGE_FORMAT(textureMipmapObject)).c_str()); if (canAppendMipmaps) { MGLOG_D("Texture mip count increased for backend ID %u, appending levels %zu..%zu", m_backendTextureId, m_prevTextureInfo.mipmapLevels, mipmapCount - 1); GLenum glInternalFormat, glType, glFormat; - TextureImpl::GenerateTextureFormatInfo(textureMipmapObject->GetFormat(), &glInternalFormat, + TextureImpl::GenerateTextureFormatInfo(MGB_STORAGE_FORMAT(textureMipmapObject), &glInternalFormat, &glFormat, &glType, targetInternal); - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = m_prevTextureInfo.mipmapLevels; level < mipmapCount; ++level) { - auto levelTexelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); + auto levelTexelSize = MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); // A level the application never defined reads back as {0, 0, 0}; now that a // sparse chain is synced rather than skipped whole, leave those undefined on // the driver instead of giving the name a 0x0 image at that index. if (levelTexelSize.x() <= 0 || levelTexelSize.y() <= 0 || levelTexelSize.z() <= 0) { - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); continue; } - auto levelByteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); - bool levelDirty = textureMipmapObject->IsStorageDirty(uploadTarget, level); + auto levelByteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); + bool levelDirty = MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); auto* pData = (levelDirty && levelByteSize != 0) - ? textureMipmapObject->MapMipmapData(uploadTarget, level) + ? MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "append-mips") : nullptr; Vector convertedUploadData; Vector widenedUploadData; const void* uploadData = PrepareFallbackUpload( - textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData, + MGB_STORAGE_FORMAT(textureMipmapObject), targetInternal, levelTexelSize, pData, levelByteSize, glType, convertedUploadData, widenedUploadData); Vector packedUploadData; - uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, + uploadData = PreparePackedNormUpload(MGB_STORAGE_FORMAT(textureMipmapObject), levelTexelSize, uploadData, levelByteSize, &glType, packedUploadData); DebugImpl::ErrorLopper::Clear(); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned const IntVec3 uploadSize = - GetBackendUploadSize(stateTextureObject->GetTarget(), levelTexelSize); - switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) { + GetBackendUploadSize(MGB_TEXTURE_TARGET(stateTextureObject), levelTexelSize); + switch (MapToBackendTextureTarget(MGB_TEXTURE_TARGET(stateTextureObject))) { case TextureTarget::Texture2D: case TextureTarget::TextureCubeMap: g_GLESFuncs.glTexImage2D( @@ -3926,7 +7810,7 @@ namespace MobileGL::MG_Backend::DirectGLES { break; default: MGLOG_E_ONCE("Unhandled texture target %s", - MG_Util::ConvertTextureTargetToString(stateTextureObject->GetTarget()).c_str()); + MG_Util::ConvertTextureTargetToString(MGB_TEXTURE_TARGET(stateTextureObject)).c_str()); break; } DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__, @@ -3940,7 +7824,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::ConvertGLEnumToString(glFormat).c_str(), MG_Util::ConvertGLEnumToString(glType).c_str(), pData); }); - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); } } needsRegeneration = false; @@ -3949,14 +7833,34 @@ namespace MobileGL::MG_Backend::DirectGLES { if (needsRegeneration) { MGLOG_D("Texture state changed significantly or not initialized, regenerating texture with ID: %u", m_backendTextureId); + // A REDEFINITION IN PLACE TAKES THE SAME GENERATION A RE-MINT TAKES (P4a fable + // seam F-3, the pre-handle half). Mutable driver storage is redefined on the + // SAME id below, so unlike RecreateBackendTexture nothing moves the FBO twins' + // memo: the frontend framebuffer versions do not see a texture's respecify + // and the id did not change. An attached texture whose format moved to one + // with the same carrier (GL_SRGB8 -> GL_SRGB8_ALPHA8 on a driver that widens + // the first) therefore kept the framebuffer's alpha-widening mask, and every + // draw into it stayed masked. The first definition is not a redefinition and + // bumps nothing; a redefinition that went through RecreateBackendTexture above + // has already bumped. +#if MOBILEGL_PIPE_PUSH + // PUSH BUILDS ONLY. This is Espryt code the pull build would share, and G1 + // keeps the pull library byte-identical to the P4a baseline (the bump resized + // this function and the renderbuffer twin's SyncToBackend: 0/0/2/0). So the + // pull build carries the pre-P4a hole until these lines land on dev on their + // own; every arm of a push build has the fix. + if (m_isInitialized && !m_backendStorageImmutable) { + ++FramebufferImpl::g_attachmentBackendIdGeneration; + } +#endif // Regenerate all mipmap levels GLenum glInternalFormat, glType, glFormat; - TextureImpl::GenerateTextureFormatInfo(textureMipmapObject->GetFormat(), &glInternalFormat, + TextureImpl::GenerateTextureFormatInfo(MGB_STORAGE_FORMAT(textureMipmapObject), &glInternalFormat, &glFormat, &glType, targetInternal); ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType); - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); if (TextureImpl::IsMultisampleTextureTarget(targetInternal)) { DebugImpl::ErrorLopper::Clear(); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned @@ -3966,8 +7870,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // stateTextureObject keeps the requested count so GL_TEXTURE_SAMPLES and // framebuffer completeness still report what the application asked for. const auto backendSamples = static_cast(ClampSamplesToBackendSupport( - GetFormatCapabilityTargetIndex(targetInternal), textureMipmapObject->GetFormat(), - glFormat, static_cast(stateTextureObject->GetSamples()))); + GetFormatCapabilityTargetIndex(targetInternal), MGB_STORAGE_FORMAT(textureMipmapObject), + glFormat, static_cast(MGB_STORAGE_SAMPLES(stateTextureObject)))); // ES 3.1 8.19 requires width/height (and depth, for the array target) >= 1, // so a degenerate size has nothing to allocate and must not reach the // driver. The frontend deallocates such an image rather than defining it @@ -3985,14 +7889,14 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glTexStorage2DMultisample( target, backendSamples, glInternalFormat, static_cast(baseSize.x()), static_cast(baseSize.y()), - stateTextureObject->HasFixedSampleLocations() ? GL_TRUE : GL_FALSE); + MGB_STORAGE_FIXED_SAMPLE_LOCATIONS(stateTextureObject) ? GL_TRUE : GL_FALSE); break; case TextureTarget::Texture2DMultisampleArray: g_GLESFuncs.glTexStorage3DMultisample( target, backendSamples, glInternalFormat, static_cast(baseSize.x()), static_cast(baseSize.y()), static_cast(baseSize.z()), - stateTextureObject->HasFixedSampleLocations() ? GL_TRUE : GL_FALSE); + MGB_STORAGE_FIXED_SAMPLE_LOCATIONS(stateTextureObject) ? GL_TRUE : GL_FALSE); break; default: MOBILEGL_ASSERT(false, "Unexpected multisample target: %d", @@ -4015,10 +7919,10 @@ namespace MobileGL::MG_Backend::DirectGLES { }); for (const auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); } } - } else if (stateTextureObject->IsImmutable() || m_imageBindableStorageRequired) { + } else if (MGB_STORAGE_IMMUTABLE(stateTextureObject) || m_imageBindableStorageRequired) { DebugImpl::ErrorLopper::Clear(); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned const IntVec3 storageSize = GetBackendUploadSize(targetInternal, baseSize); @@ -4054,21 +7958,22 @@ namespace MobileGL::MG_Backend::DirectGLES { ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { - auto levelByteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); - const bool levelDirty = textureMipmapObject->IsStorageDirty(uploadTarget, level); + auto levelByteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); + const bool levelDirty = MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level); if (levelDirty && levelByteSize != 0) { auto levelTexelSize = - textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); + MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); - auto* pData = textureMipmapObject->MapMipmapData(uploadTarget, level); + auto* pData = MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "immutable-regen"); Vector convertedUploadData; Vector widenedUploadData; const void* uploadData = PrepareFallbackUpload( - textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData, + MGB_STORAGE_FORMAT(textureMipmapObject), targetInternal, levelTexelSize, pData, levelByteSize, glType, convertedUploadData, widenedUploadData); Vector packedUploadData; uploadData = - PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, + PreparePackedNormUpload(MGB_STORAGE_FORMAT(textureMipmapObject), levelTexelSize, uploadData, levelByteSize, &glType, packedUploadData); Vector imageWidenedUploadData; uploadData = PrepareImageWidenedUpload(imageWidening, levelTexelSize, uploadData, @@ -4109,7 +8014,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MG_Util::ConvertGLEnumToString(glType).c_str(), pData); }); } - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); } } } else { @@ -4117,28 +8022,29 @@ namespace MobileGL::MG_Backend::DirectGLES { ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { - auto levelTexelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); + auto levelTexelSize = MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); // See the append-mips loop: an undefined level stays undefined on the // driver rather than becoming a 0x0 image. if (levelTexelSize.x() <= 0 || levelTexelSize.y() <= 0 || levelTexelSize.z() <= 0) { - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); continue; } - auto levelByteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); - bool levelDirty = textureMipmapObject->IsStorageDirty(uploadTarget, level); + auto levelByteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); + bool levelDirty = MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); auto* pData = (levelDirty && levelByteSize != 0) - ? textureMipmapObject->MapMipmapData(uploadTarget, level) + ? MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "mutable-regen") : nullptr; Vector convertedUploadData; Vector widenedUploadData; const void* uploadData = PrepareFallbackUpload( - textureMipmapObject->GetFormat(), targetInternal, levelTexelSize, pData, + MGB_STORAGE_FORMAT(textureMipmapObject), targetInternal, levelTexelSize, pData, levelByteSize, glType, convertedUploadData, widenedUploadData); Vector packedUploadData; uploadData = - PreparePackedNormUpload(textureMipmapObject->GetFormat(), levelTexelSize, + PreparePackedNormUpload(MGB_STORAGE_FORMAT(textureMipmapObject), levelTexelSize, uploadData, levelByteSize, &glType, packedUploadData); MGLOG_D("%s: target: %s: syncing mip %d: %dx%dx%d, byteSize = %d, pData = %p, " "levelDirty = %s", @@ -4148,7 +8054,7 @@ namespace MobileGL::MG_Backend::DirectGLES { DebugImpl::ErrorLopper::Clear(); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned - auto textureTarget = stateTextureObject->GetTarget(); + auto textureTarget = MGB_TEXTURE_TARGET(stateTextureObject); const IntVec3 uploadSize = GetBackendUploadSize(textureTarget, levelTexelSize); switch (MapToBackendTextureTarget(textureTarget)) { case TextureTarget::Texture2D: @@ -4187,7 +8093,7 @@ namespace MobileGL::MG_Backend::DirectGLES { }); MGLOG_D("Regenerated mipmap level %d for texture with ID: %u", level, m_backendTextureId); - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); } } } @@ -4197,35 +8103,35 @@ namespace MobileGL::MG_Backend::DirectGLES { { // Update all dirty mipmap levels if (TextureImpl::IsMultisampleTextureTarget(targetInternal)) { - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); for (const auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { - if (textureMipmapObject->IsStorageDirty(uploadTarget, level)) { - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + if (MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level)) { + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); } } } break; } - const auto mipmapCount = textureMipmapObject->GetMipmapLevelCount(); + const auto mipmapCount = MGB_STORAGE_LEVELS(textureMipmapObject); GLenum glInternalFormat, glType, glFormat; - TextureImpl::GenerateTextureFormatInfo(textureMipmapObject->GetFormat(), &glInternalFormat, + TextureImpl::GenerateTextureFormatInfo(MGB_STORAGE_FORMAT(textureMipmapObject), &glInternalFormat, &glFormat, &glType, targetInternal); // The storage this level is being written into was widened when it was minted // (see above), so the transfer pair has to describe the carrier here too - ES // requires glTexSubImage's `format` to match the storage's base internal // format, so a GL_RG upload into a GL_RGBA32F image is GL_INVALID_OPERATION. ApplyImageBindableStorageWidening(imageWidening, &glInternalFormat, &glFormat, &glType); - const auto& uploadTargets = textureMipmapObject->GetUploadTargets(); + const auto& uploadTargets = MGB_UPLOAD_TARGETS(textureMipmapObject); ScopedDefaultUnpackState unpackState; for (auto& uploadTarget : uploadTargets) { for (SizeT level = 0; level < mipmapCount; ++level) { - if (!textureMipmapObject->IsStorageDirty(uploadTarget, level)) { + if (!MGB_LEVEL_NEEDS_UPLOAD(textureMipmapObject, uploadTarget, level)) { continue; } - auto byteSize = textureMipmapObject->GetMipmapByteSize(uploadTarget, level); + auto byteSize = MGB_LEVEL_BYTE_SIZE(textureMipmapObject, uploadTarget, level); if (byteSize == 0) { MGLOG_D("Mipmap level %d has no data, skipping update.", level); continue; @@ -4235,8 +8141,8 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("%s: Updating dirty mip %d for texture ID %u, size: %dx%d, " "byteSize: %d", __func__, level, m_backendTextureId, - textureMipmapObject->GetMipmapTexelSize(uploadTarget, level).x(), - textureMipmapObject->GetMipmapTexelSize(uploadTarget, level).y(), byteSize); + MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level).x(), + MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level).y(), byteSize); auto glUploadTarget = ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); BufferImpl::BindPixelUnpackBufferId(0); // no-op once the resting 0 state is pinned @@ -4245,15 +8151,16 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("%s(%s:%d) ES error: %s", func, file, line, MG_Util::ConvertGLEnumToString(err).c_str()); }); - auto texelSize = textureMipmapObject->GetMipmapTexelSize(uploadTarget, level); - const void* mipData = textureMipmapObject->MapMipmapData(uploadTarget, level); + auto texelSize = MGB_LEVEL_TEXEL_SIZE(textureMipmapObject, uploadTarget, level); + const void* mipData = MGB_LEVEL_TEXELS(textureMipmapObject, uploadTarget, level, + "dirty-level"); Vector convertedUploadData; Vector widenedUploadData; const void* uploadData = PrepareFallbackUpload( - textureMipmapObject->GetFormat(), targetInternal, texelSize, mipData, byteSize, + MGB_STORAGE_FORMAT(textureMipmapObject), targetInternal, texelSize, mipData, byteSize, glType, convertedUploadData, widenedUploadData); Vector packedUploadData; - uploadData = PreparePackedNormUpload(textureMipmapObject->GetFormat(), texelSize, + uploadData = PreparePackedNormUpload(MGB_STORAGE_FORMAT(textureMipmapObject), texelSize, uploadData, byteSize, &glType, packedUploadData); // Leaves `uploadData` pointing at its own buffer when it fires, which // is exactly what takes the sub-rect fast path below out of play: that @@ -4263,7 +8170,7 @@ namespace MobileGL::MG_Backend::DirectGLES { uploadData = PrepareImageWidenedUpload(imageWidening, texelSize, uploadData, byteSize, imageWidenedUploadData); const IntVec3 uploadSize = - GetBackendUploadSize(stateTextureObject->GetTarget(), texelSize); + GetBackendUploadSize(MGB_TEXTURE_TARGET(stateTextureObject), texelSize); // Sub-rect upload: when only a region of the level changed (a // 16x16 sprite in a 1024x512 atlas, the per-frame lightmap) and // the shadow bytes go to the driver unconverted, upload just that @@ -4271,7 +8178,53 @@ namespace MobileGL::MG_Backend::DirectGLES { // Conversion fallbacks rewrite the whole level into a fresh // buffer, so they stay on the full-level path, as do targets // whose backend upload size differs from the shadow's texel size. + // P4a (D-D6, and ARCHITECTURE.md:321's ONE item moved out of the + // Espryt do-not-touch list): the DECISION below is unchanged and + // stays on the server - it is the side that pays the GPU cost, and + // the union-box-versus-N-rects choice is worth 16x the bytes on one + // axis and +6 ms/frame of Mali job cost on the other. What moves is + // the SOURCE of the shape and of the strides. + // + // On the handle arm the box and the rect list come from the record + // the client emitted; on the legacy arm they come from the frontend's + // own rect model, exactly as before. Both spellings feed the same + // three staging shapes and the same StageBlocksIntoUnpackRing, which + // is byte-identical (G5). +#if MOBILEGL_PIPE_PUSH + const MG_Pipe::MGPipeResourceRecord::PendingUpload* pendingUpload = + pushedStorage != nullptr + ? FindPipeTextureUpload(*pushedStorage, static_cast(uploadTarget), + static_cast(level)) + : nullptr; + const auto dirtyRegion = [&]() -> MG_State::GLState::MipmapDirtyRegion { + if (pendingUpload == nullptr) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (tx): a level this arm owes with NO pending upload behind it + // was dirtied by the GPU (T5), and the dirty answer is the + // server's own mark on the staged shadow - the whole level, + // because a generation touches all of it. The client is never + // asked (§2.2's last row). + if (MGB_STAGED_TEXTURE_LIVE) { + const IntVec3 gpuExtent = + MG_Remote::Server::ServerStagedTexture().LevelExtentOrUndefined( + MG_Remote::Server::StagedTextureStore::KeyForHandle(pushedRes), + static_cast(uploadTarget), static_cast(level)); + return MG_State::GLState::MipmapDirtyRegion{IntVec3{0, 0, 0}, gpuExtent}; + } +#endif + return textureMipmapObject->GetStorageDirtyRegion(uploadTarget, level); + } + // MGPBox is {origin, extent}; MipmapDirtyRegion is {lo, hi}. The + // conversion is the whole difference between the two spellings. + const auto& box = pendingUpload->UnionBox; + return MG_State::GLState::MipmapDirtyRegion{ + IntVec3{box.X, box.Y, box.Z}, + IntVec3{box.X + static_cast(box.W), box.Y + static_cast(box.H), + box.Z + static_cast(box.D)}}; + }(); +#else const auto dirtyRegion = textureMipmapObject->GetStorageDirtyRegion(uploadTarget, level); +#endif const SizeT texelCount = static_cast(texelSize.x()) * static_cast(texelSize.y()) * static_cast(std::max(texelSize.z(), 1)); @@ -4285,8 +8238,94 @@ namespace MobileGL::MG_Backend::DirectGLES { const IntVec3 regionSize = {dirtyRegion.hi.x() - dirtyRegion.lo.x(), dirtyRegion.hi.y() - dirtyRegion.lo.y(), dirtyRegion.hi.z() - dirtyRegion.lo.z()}; + // STRIDES ARE CARRIED, NEVER INFERRED (D-D3), once there is a record + // to carry them: a sub-rect's rows are not contiguous in the level + // shadow, so each MGPSubRegion states the level's row and slice pitch + // and 0 means "tightly packed", which is what a whole-level region + // sends. The legacy arm keeps computing them from the level extent + // and the bytes-per-texel it just derived - the same two numbers, + // from the other side of the wire. +#if MOBILEGL_PIPE_PUSH + // m-2: THE PITCH IS THE LEVEL'S (D-D3), so every region of one level + // states the same one - and v1 read Regions.front() and would have + // strided the rest at the first one's pitch, silently, if a record + // ever disagreed. This is the assert the review asked for rather than + // a per-region loop feeding per-region strides: a record whose regions + // disagree is corrupt, not a shape the upload planner should learn. + // On disagreement the carried pair is DROPPED and the level's own + // extent times bpp is used, which is what the legacy arm computes. + Bool pendingStridesAgree = true; + if (pendingUpload != nullptr && !pendingUpload->Regions.empty()) { + const auto& firstRegion = pendingUpload->Regions.front(); + for (const auto& region : pendingUpload->Regions) { + if (region.SrcRowStride == firstRegion.SrcRowStride && + region.SrcSliceStride == firstRegion.SrcSliceStride) { + continue; + } + pendingStridesAgree = false; + MGLOG_E_ONCE("MGPipe: texture %u level %u carries regions with two " + "different level pitches (%u/%u and %u/%u) - a pitch is " + "the LEVEL's, so the carried pair is dropped and the " + "extent is used instead", + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject), + static_cast(level), firstRegion.SrcRowStride, + firstRegion.SrcSliceStride, region.SrcRowStride, + region.SrcSliceStride); + break; + } + } + const SizeT carriedRowBytes = + (pendingUpload != nullptr && pendingStridesAgree && + !pendingUpload->Regions.empty() && + pendingUpload->Regions.front().SrcRowStride != 0) + ? static_cast(pendingUpload->Regions.front().SrcRowStride) + : 0; + const SizeT carriedSliceBytes = + (pendingUpload != nullptr && pendingStridesAgree && + !pendingUpload->Regions.empty() && + pendingUpload->Regions.front().SrcSliceStride != 0) + ? static_cast(pendingUpload->Regions.front().SrcSliceStride) + : 0; + const SizeT levelRowBytes = + carriedRowBytes != 0 ? carriedRowBytes : static_cast(texelSize.x()) * bpp; + const SizeT levelSliceBytes = carriedSliceBytes != 0 + ? carriedSliceBytes + : static_cast(texelSize.y()) * levelRowBytes; + // N-5: THE SAME DISCIPLINE FOR SrcOffset AS FOR THE STRIDES, and the + // asymmetry it removes was the review's point: the strides got a loud + // refusal in the commit that started reading them while the offset - + // which decides the ADDRESS glTexSubImage reads (w*bpp x h x d) bytes + // from - was consumed raw. The cross-check is free, because + // rectShadowPtr four lines down computes exactly the same number from + // the region origin and the two pitches. A record whose regions + // disagree with their own origins is corrupt, so on disagreement the + // carried offsets are DROPPED - every use site falls back to the + // derived pointer, which is what the legacy arm sends - and one named + // line says so. Gated on subRectEligible because bpp is 0 otherwise + // and the regions are not read at all. + Bool pendingOffsetsAgree = true; + if (pendingUpload != nullptr && subRectEligible) { + for (const auto& region : pendingUpload->Regions) { + const SizeT derivedOffset = static_cast(region.Z) * levelSliceBytes + + static_cast(region.Y) * levelRowBytes + + static_cast(region.X) * bpp; + if (static_cast(region.SrcOffset) == derivedOffset) continue; + pendingOffsetsAgree = false; + MGLOG_E_ONCE("MGPipe: texture %u level %u carries a region whose SrcOffset " + "%llu is not its own origin (%d,%d,%d) at the level's pitch " + "(%llu expected) - the carried offsets are dropped and every " + "rect is read from the shadow instead", + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject), static_cast(level), + static_cast(region.SrcOffset), region.X, + region.Y, region.Z, + static_cast(derivedOffset)); + break; + } + } +#else const SizeT levelRowBytes = static_cast(texelSize.x()) * bpp; const SizeT levelSliceBytes = static_cast(texelSize.y()) * levelRowBytes; +#endif const Uint8* regionPtr = static_cast(uploadData) + static_cast(dirtyRegion.lo.z()) * levelSliceBytes + @@ -4305,6 +8344,32 @@ namespace MobileGL::MG_Backend::DirectGLES { dirtyRects[MG_State::GLState::MipmapStorage::kMaxDirtyRects]; SizeT dirtyRectCount = 0; if (subRectEligible) { +#if MOBILEGL_PIPE_PUSH + if (pendingUpload != nullptr) { + // RegionCount == 0 is LEGAL and means "the union box is the + // whole story" (D-D3), which is the same statement the + // frontend's GetStorageDirtyRects makes by returning 0 - so + // both arms reach the box branch below by the same route. + dirtyRectCount = + std::min(pendingUpload->Regions.size(), + MG_State::GLState::MipmapStorage::kMaxDirtyRects); + for (SizeT r = 0; r < dirtyRectCount; ++r) { + const auto& region = pendingUpload->Regions[r]; + dirtyRects[r] = MG_State::GLState::MipmapDirtyRegion{ + IntVec3{region.X, region.Y, region.Z}, + IntVec3{region.X + static_cast(region.W), + region.Y + static_cast(region.H), + region.Z + static_cast(region.D)}}; + } + } else +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // tx: with an active transport the rect list is the record's (the + // pendingUpload arm above) or nothing - the server's GPU-dirty mark + // is whole-level and has no scatter refinement to hand out, and the + // frontend's rect model is not this side's to read. + if (!MGB_STAGED_TEXTURE_LIVE) +#endif dirtyRectCount = textureMipmapObject->GetStorageDirtyRects( uploadTarget, level, dirtyRects, MG_State::GLState::MipmapStorage::kMaxDirtyRects); @@ -4324,6 +8389,34 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(rect.lo.y()) * levelRowBytes + static_cast(rect.lo.x()) * bpp; }; +// m-1 / D-D3, "carried, never inferred": MGPSubRegion::SrcOffset is the byte offset of that +// region's origin into the level shadow - the same number the lambda above derives from the +// origin and the two strides - and on the handle arm it is READ rather than re-derived, after +// the cross-check above has agreed that the two spellings say the same thing (N-5). Every site +// that uses it is inside `subRectEligible`, which requires uploadData == mipData, so the base +// the offset is relative to is the level shadow itself and never a conversion buffer. +// +// THREE USE SITES, AND THE FIRST OF THEM IS UNREACHABLE (N-5's second half, and the correction +// to what esprytobj-v2.md m-1 claimed): dirtyRectCount is forced to 0 whenever +// UnpackRingAvailable(), and the staging-block loop that takes MGB_RECT_SRC_PTR(r, rect) first +// requires `ringUsable && dirtyRectCount >= 2`. The two conditions cannot both hold. That shape +// is pre-existing (the ring/scatter decision, not this package's), so the loop is left as it +// stands and the count is corrected here instead of the loop being deleted underneath it. +// +// A MACRO AND NOT A SECOND LAMBDA, for DV-9's measured reason: this function's nine ErrorLopper +// lambdas are mangled ...::$_N BY POSITION, so one more renumbers every one of them and moves +// the PULL build's symbol set, whose admitted-change set is EMPTY. The pull expansion is the +// pre-P4a call with one pair of parentheses. #undef'd with the rest. +#if MOBILEGL_PIPE_PUSH +#define MGB_RECT_SRC_PTR(idx, rect) \ + ((pendingUpload != nullptr && pendingOffsetsAgree && \ + static_cast(idx) < pendingUpload->Regions.size()) \ + ? static_cast(uploadData) + \ + static_cast(pendingUpload->Regions[static_cast(idx)].SrcOffset) \ + : rectShadowPtr(rect)) +#else +#define MGB_RECT_SRC_PTR(idx, rect) (rectShadowPtr(rect)) +#endif // Unpack-ring staging plan, decided ONCE for whichever branch // below runs: either every glTexSubImage of this level sources // from the ring or none does, so the pixel-unpack binding is @@ -4348,7 +8441,7 @@ namespace MobileGL::MG_Backend::DirectGLES { for (SizeT r = 0; r < dirtyRectCount; ++r) { const auto& rect = dirtyRects[r]; stagingBlocks[r] = { - rectShadowPtr(rect), + MGB_RECT_SRC_PTR(r, rect), static_cast(rect.hi.x() - rect.lo.x()) * bpp, static_cast(rect.hi.y() - rect.lo.y()), static_cast(std::max(rect.hi.z() - rect.lo.z(), 1)), @@ -4394,7 +8487,43 @@ namespace MobileGL::MG_Backend::DirectGLES { if (ringStaged) { BufferImpl::BindPixelUnpackBufferId(BufferImpl::UnpackRingBufferId()); } - switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) { + if (MG_Util::PipeStats::Enabled()) { + // One emission per (upload target, level) that ships texels; + // the switch below turns it into either one union-box job or + // dirtyRectCount rect jobs. The box/rect split is counted + // separately from the bytes on purpose: SSIM is blind to it + // and the +6 ms/frame Mali cliff was a shape regression, not + // a byte regression (plan section 7.3). + const Bool rectShape = subRectEligible && dirtyRectCount >= 2; + Uint64 shippedBytes = 0; + if (rectShape) { + for (SizeT r = 0; r < dirtyRectCount; ++r) { + const auto& rect = dirtyRects[r]; + shippedBytes += static_cast(rect.hi.x() - rect.lo.x()) * + static_cast(rect.hi.y() - rect.lo.y()) * + static_cast(std::max(rect.hi.z() - rect.lo.z(), 1)) * + static_cast(bpp); + } + } else if (subRectEligible) { + shippedBytes = static_cast(regionSize.x()) * + static_cast(regionSize.y()) * + static_cast(std::max(regionSize.z(), 1)) * + static_cast(bpp); + } else { + shippedBytes = static_cast(byteSize); + } + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageTexture, + shippedBytes); + MG_Util::PipeStats::AddCalls( + MG_Util::PipeStats::CallClass::TextureUploadEmissions, 1); + MG_Util::PipeStats::AddCalls( + rectShape ? MG_Util::PipeStats::CallClass::TextureUploadRectEmissions + : MG_Util::PipeStats::CallClass::TextureUploadBoxEmissions, + 1); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadJobs, + rectShape ? static_cast(dirtyRectCount) : 1u); + } + switch (MapToBackendTextureTarget(MGB_TEXTURE_TARGET(stateTextureObject))) { case TextureTarget::Texture2D: case TextureTarget::TextureCubeMap: if (subRectEligible && dirtyRectCount >= 2) { @@ -4407,7 +8536,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(rect.hi.y() - rect.lo.y()), glFormat, glType, ringStaged ? UnpackRingPixelOffset(stagingBlocks[r].offset) - : static_cast(rectShadowPtr(rect))); + : static_cast(MGB_RECT_SRC_PTR(r, rect))); } // The surrounding ScopedDefaultUnpackState shadow says 0. if (!ringStaged) g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); @@ -4451,7 +8580,7 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(rect.hi.z() - rect.lo.z()), glFormat, glType, ringStaged ? UnpackRingPixelOffset(stagingBlocks[r].offset) - : static_cast(rectShadowPtr(rect))); + : static_cast(MGB_RECT_SRC_PTR(r, rect))); } if (!ringStaged) { g_GLESFuncs.glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); @@ -4487,7 +8616,7 @@ namespace MobileGL::MG_Backend::DirectGLES { break; default: MGLOG_E_ONCE("Unhandled texture target %s", - MG_Util::ConvertTextureTargetToString(stateTextureObject->GetTarget()).c_str()); + MG_Util::ConvertTextureTargetToString(MGB_TEXTURE_TARGET(stateTextureObject)).c_str()); break; } if (ringStaged) { @@ -4496,7 +8625,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // meant to stay a shadow no-op). BufferImpl::BindPixelUnpackBufferId(0); } - textureMipmapObject->MarkStorageDirty(uploadTarget, level, false); + MGB_LEVEL_UPLOAD_DONE(textureMipmapObject, uploadTarget, level); } } } @@ -4505,23 +8634,81 @@ namespace MobileGL::MG_Backend::DirectGLES { case TextureStorageType::Buffer: { auto* textureBufferObject = static_cast(stateTextureObject.get()); - auto& slot = textureBufferObject->GetBufferBindingSlot(); - auto& buffer = slot.GetBoundObject(); - if (!buffer) { +#if MOBILEGL_PIPE_PUSH + // P5e (tx2), scout R5 RESOLVED. THE BACKING BUFFER IS THE DESCRIPTOR'S, AND SO IS + // ITS WINDOW - `Desc.BufferForTexBuffer` names the store and `Desc.BufOffset / + // BufSize` name the range, so the frontend binding slot below is not read at all + // on the handle arm. + // + // The one thing that argument had to answer is D-A4's `frontend` parameter of + // EnsureBufferResourceForHandle, which existed for the emulated-persistent-map + // question. The E note's reading is confirmed at this head: that function's three + // frontend reads (Managers.cpp's MappedData / SyncPersistentMappedRange / + // HasDefinedContent) are all inside its MONOLITH arm - under an active transport + // it is client-free (kimi audit row 29) - so `nullptr` is not a hole, it is the + // whole of the split answer, and it is what §4.2's Buffer row already prescribes + // (`EnsureBufferResourceForHandle(nullptr, h)`). + // + // WHOLE-VS-RANGE likewise stops asking the buffer object for its size: the + // sentinel kMGPipeWholeBuffer IS "the whole store", which is the question + // `rangeSize == buffer->GetSize()` was spelling the long way round. + MG_Pipe::MGPipeHandle pushedTexBuffer = MG_Pipe::kMGPipeNullHandle; + if (pushedStorage != nullptr) { + pushedTexBuffer = pushedStorage->Desc.BufferForTexBuffer; + if (MG_Pipe::MGPipeHandleIsNull(pushedTexBuffer)) { + MGLOG_E_ONCE("MGPipe: buffer texture %u's descriptor names no backing buffer on " + "the handle arm, so it cannot be backed (handle {%u, %u})", + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject), pushedRes.Slot, pushedRes.Gen); + return; + } + } + // P5e (fix1 follow-up, ID-110): the third conjunct is the one fix1's report + // asked for a ruling about. This arm hands the backing-buffer lookup + // kNoFrontendBuffer and leans on the record answering; it was unreachable + // under monolith only because every writer of a handle-note is + // transport-gated - safe by a chain rather than by its own statement, which + // is exactly the shape that cost the monolith arm 137 scenarios. Under a + // transport the conjunct is already implied by `pushedStorage != nullptr`, so + // this changes no behaviour and closes no door; it says the thing instead of + // depending on it. + const Bool texBufferByRecord = pushedStorage != nullptr && + MGB_TEXTURE_HANDLE_ARM_OFF(*this) == false && + MGB_TEXTURE_RECORD_ARM_SELECTED(); +#else + const Bool texBufferByRecord = false; +#endif + static const SharedPtr kNoFrontendBuffer{}; + auto& buffer = texBufferByRecord ? kNoFrontendBuffer + : textureBufferObject->GetBufferBindingSlot().GetBoundObject(); + if (!buffer && !texBufferByRecord) { MGLOG_D("Texture buffer object with ID: %u has no bound buffer, skipping sync.", - stateTextureObject->GetExternalIndex()); + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject)); return; } - auto bufferIndex = buffer->GetExternalIndex(); + auto bufferIndex = buffer ? buffer->GetExternalIndex() : 0u; +#if MOBILEGL_PIPE_PUSH + // The memo key is the HANDLE's slot on the handle arm: a GL name is never an + // identity (section 4.2.1), and the record's Serial covers a slot recycled at a + // new generation because a respecify moves it. + currentTextureInfo.bufferExternalIndex = + pushedStorage != nullptr ? static_cast(pushedTexBuffer.Slot) : bufferIndex; +#else currentTextureInfo.bufferExternalIndex = bufferIndex; +#endif Bool needsRegeneration = !m_isInitialized || (currentTextureInfo != m_prevTextureInfo); // Need to sync texture buffer if not synced yet +#if MOBILEGL_PIPE_PUSH + auto* backendBufferResource = + pushedStorage != nullptr ? BufferImpl::EnsureBufferResourceForHandle(buffer, pushedTexBuffer) + : BufferImpl::EnsureBufferResource(buffer); +#else auto* backendBufferResource = BufferImpl::EnsureBufferResource(buffer); +#endif if (!backendBufferResource || backendBufferResource->id == 0) { MGLOG_E_ONCE("Failed to sync backing buffer for texture buffer with ID: %u", - stateTextureObject->GetExternalIndex()); + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject)); return; } @@ -4529,7 +8716,7 @@ namespace MobileGL::MG_Backend::DirectGLES { auto backendId = backendBufferResource->id; GLenum glInternalFormat, glType, glFormat; - TextureImpl::GenerateTextureFormatInfo(textureBufferObject->GetFormat(), &glInternalFormat, &glFormat, + TextureImpl::GenerateTextureFormatInfo(MGB_STORAGE_FORMAT(textureBufferObject), &glInternalFormat, &glFormat, &glType, TextureTarget::TextureBuffer); // The view half of the buffer-image SPLIT. A buffer texture has no storage of its // own to widen, but the VIEW its format describes can be re-described one @@ -4546,7 +8733,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // IMAGE binding needs the split, so only the image binding's name carries it. const GLenum bufferImageSplitFormat = m_imageBindableStorageRequired - ? TextureImpl::GetImageBindableBufferSplitFormat(textureBufferObject->GetFormat()) + ? TextureImpl::GetImageBindableBufferSplitFormat(MGB_STORAGE_FORMAT(textureBufferObject)) : GL_UNKNOWN_MGL; if (needsRegeneration) { @@ -4569,32 +8756,60 @@ namespace MobileGL::MG_Backend::DirectGLES { "shader declaring a samplerBuffer will fail to compile. MobileGL " "still advertises GL_MAX_TEXTURE_BUFFER_SIZE = %d because an " "OpenGL 4.x context may not report 0.", - stateTextureObject->GetExternalIndex(), GetBufferTextureTierName(), + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject), GetBufferTextureTierName(), g_GLESCapabilities.MaxTextureBufferSize); break; } MGLOG_D("Texture state changed significantly or not initialized, regenerating texture buffer with " "ID: %u, buffer ID: %u, buffer size: %zu, format: %s", - m_backendTextureId, backendId, buffer->GetSize(), + m_backendTextureId, backendId, buffer ? buffer->GetSize() : rangeSize, MG_Util::ConvertGLEnumToString(glInternalFormat).c_str()); // A texture that names a window of the buffer needs the range form; the // whole-buffer forms report offset 0 and the buffer's current size, which // glTexBuffer expresses more directly (and works where the range entry point // is absent). +#if MOBILEGL_PIPE_PUSH + // The WINDOW is carried: MGPResourceDesc::BufOffset / BufSize, with + // kMGPipeWholeBuffer (~0) meaning "the whole store, resolved live" - which is + // why the whole-buffer arm still asks the BUFFER for its size. That is the + // buffer family's own object (P3a's, not this record's), and resolving it + // here is exactly what the sentinel is documented to mean. + const SizeT rangeOffset = pushedStorage != nullptr + ? static_cast(pushedStorage->Desc.BufOffset) + : textureBufferObject->GetBufferRangeOffset(); + // P5e (tx2): "the whole store" is the SENTINEL on the handle arm, not a size + // compare against a frontend BufferObject this arm may not name. The monolith + // arm keeps resolving the sentinel live, exactly as its comment says. + const Bool wholeBufferRange = + pushedStorage != nullptr + ? (pushedStorage->Desc.BufOffset == 0 && + pushedStorage->Desc.BufSize == MG_Pipe::kMGPipeWholeBuffer) + : (textureBufferObject->GetBufferRangeOffset() == 0 && + textureBufferObject->GetBufferRangeSizeInBytes() == buffer->GetSize()); + const SizeT rangeSize = + pushedStorage == nullptr + ? textureBufferObject->GetBufferRangeSizeInBytes() + : (pushedStorage->Desc.BufSize == MG_Pipe::kMGPipeWholeBuffer + ? (buffer ? buffer->GetSize() : 0u) + : static_cast(pushedStorage->Desc.BufSize)); +#else + const Bool wholeBufferRange = textureBufferObject->GetBufferRangeOffset() == 0 && + textureBufferObject->GetBufferRangeSizeInBytes() == buffer->GetSize(); const SizeT rangeOffset = textureBufferObject->GetBufferRangeOffset(); const SizeT rangeSize = textureBufferObject->GetBufferRangeSizeInBytes(); +#endif // Through CallTexBuffer/CallTexBufferRange rather than g_GLESFuncs directly: // the unsuffixed entry points are the ES 3.2 core spelling, and a driver // whose buffer textures come from EXT/OES_texture_buffer exports the // suffixed ones instead. The dispatchers pick whichever this tier ships. - if (rangeOffset == 0 && rangeSize == buffer->GetSize()) { + if (wholeBufferRange) { CallTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); } else if (!CallTexBufferRange(GL_TEXTURE_BUFFER, glInternalFormat, backendId, static_cast(rangeOffset), static_cast(rangeSize))) { MGLOG_W_ONCE("Texture buffer %u names a sub-range but the driver has no " "glTexBufferRange; binding the whole buffer instead", - stateTextureObject->GetExternalIndex()); + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject)); CallTexBuffer(GL_TEXTURE_BUFFER, glInternalFormat, backendId); } DebugImpl::ErrorLopper::Loop( @@ -4616,10 +8831,10 @@ namespace MobileGL::MG_Backend::DirectGLES { if (m_bufferImageSplitViewId == 0) { MGLOG_E_ONCE("Failed to generate the buffer-image split view for texture %u; " "its image binding will read the unsplit view.", - stateTextureObject->GetExternalIndex()); + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject)); } else { g_GLESFuncs.glBindTexture(GL_TEXTURE_BUFFER, m_bufferImageSplitViewId); - if (rangeOffset == 0 && rangeSize == buffer->GetSize()) { + if (wholeBufferRange) { CallTexBuffer(GL_TEXTURE_BUFFER, bufferImageSplitFormat, backendId); } else if (!CallTexBufferRange(GL_TEXTURE_BUFFER, bufferImageSplitFormat, backendId, static_cast(rangeOffset), @@ -4643,8 +8858,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // renders wrong; throwing unwinds through the C GL ABI and kills the process. MGLOG_E_ONCE("DirectGLES texture sync: no upload path for storage type %d on texture %u; " "skipping this sync", - static_cast(stateTextureObject->GetStorageType()), - stateTextureObject->GetExternalIndex()); + static_cast(MGB_TEXTURE_STORAGE_KIND(pushedStorage, stateTextureObject)), + MGB_TEXTURE_DIAG_NAME(pushedStorage, stateTextureObject)); break; } @@ -4653,6 +8868,21 @@ namespace MobileGL::MG_Backend::DirectGLES { }); m_prevTextureInfo = currentTextureInfo; +#if MOBILEGL_PIPE_PUSH + if (pushedStorage != nullptr) { + // The handle arm's one stamp. Deliberately AFTER the switch, at the same + // instant the legacy arm stamps its four keys, so a bail arm that returned + // early leaves it untouched and the next sync re-runs - which is the property + // the pending set exists to make safe (D-D5). + // + // The record pointer was resolved before any driver work and TextureResources + // has not been grown since (nothing this function calls emits a create), so + // re-reading Serial through it is sound; the PendingUploads entries it named + // have been consumed one at a time by MGB_LEVEL_UPLOAD_DONE. + m_syncedResourceSerial = pushedStorage->Serial; + return; + } +#endif // Everything dirty at entry is uploaded (or provably has no bytes to // upload); stamp the version so per-draw re-syncs short-circuit until // the next CPU-side mutation. @@ -4660,14 +8890,126 @@ namespace MobileGL::MG_Backend::DirectGLES { // Same instant, so the cheap gate's keys describe exactly this synced state. // Only Mipmap storage may arm it - the gate refuses other storage types anyway, // but a stale trio must not linger on an object that later switches type. - if (MG_State::pGLContext && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap) { - m_syncedShapeContextId = MG_State::pGLContext->GetTextureContextId(); - m_syncedShapeGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + if (MGB_CTX_LIVE && stateTextureObject->GetStorageType() == TextureStorageType::Mipmap) { + m_syncedShapeContextId = MGB_CTX->GetTextureContextId(); + m_syncedShapeGeneration = MGB_CTX->GetSamplingResolutionGeneration(); m_syncedShapeParamsVersion = stateTextureObject->GetTextureParamsVersion(); } else { m_syncedShapeContextId = 0; } } +#undef MGB_LEVEL_NEEDS_UPLOAD +#undef MGB_LEVEL_UPLOAD_DONE +#undef MGB_RECT_SRC_PTR +#undef MGB_STORAGE_FORMAT +#undef MGB_STORAGE_BASE_SIZE +#undef MGB_STORAGE_LEVELS +#undef MGB_STORAGE_SAMPLES +#undef MGB_STORAGE_FIXED_SAMPLE_LOCATIONS +#undef MGB_STORAGE_IMMUTABLE +#undef MGB_STORAGE_KIND +#undef MGB_TEXTURE_TARGET +#undef MGB_UPLOAD_TARGETS +#undef MGB_LEVEL_TEXEL_SIZE +#undef MGB_LEVEL_BYTE_SIZE +#undef MGB_LEVEL_TEXELS +#if MOBILEGL_BUILD_DISAGGREGATED +#undef MGB_STAGED_TEXTURE_LIVE +#endif + +#if MOBILEGL_PIPE_PUSH + // P5e (tx2), CONTRACT-P5E §5.2: THIS TWIN'S OWN RECORD. On the by-handle arm the handle + // is already known - the caller resolved the record before it resolved this twin and + // noted the handle on the twin itself - so the client allocator is not probed at all. + // What is left below is the MONOLITH GLUE half (HandleOfBuffer's shape): a twin minted + // from a frontend object and never adopted by handle still re-derives one. + const MG_Pipe::MGPipeResourceRecord* BackendTextureObject::ResolveOwnRecord( + const SharedPtr& stateTextureObject) const { + MG_Pipe::MGPipeHandle res = m_pushedSyncHandle; + if (MG_Pipe::MGPipeHandleIsNull(res)) { + if (!stateTextureObject) return nullptr; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed resolution, named debt inside the + // scope - the by-handle arm above is what retires it. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + res = g_backendTextureObjects.HandleOf(stateTextureObject.get()); + } + return PipeTextureRecordForHandle(res); + } + + const SamplerParameters* BackendTextureObject::ResolvePushedBuiltinSampler( + const SharedPtr& stateTextureObject, + const MG_Pipe::MGPipeResourceRecord* record) { + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: texture %u has no applier record on the handle arm, so its " + "built-in sampler cannot be pushed (handle {%u, %u})", + TextureDiagName(stateTextureObject, record), m_pushedSyncHandle.Slot, + m_pushedSyncHandle.Gen); + return nullptr; + } + // NO set_texture_params HAS BEEN APPLIED FOR THIS TEXTURE YET, which is not the same + // thing as a malformed one and must not be read as one. ParamsSerial is the + // applier's own "have I ever stored a params record here" (it is bumped on every + // set_texture_params and starts at 0), and Params is value-initialised beside it - + // so on a texture the application has never given a parameter to, BuiltinSampler is + // null because the record was never written, not because a client wrote a null. + // There is nothing to push in that state; SyncTextureParamsToBackend's own gate + // reaches the same conclusion silently (m_syncedParamsSerial starts at 0 too and the + // compare skips), and this decline is made silent for the same reason. Found by the + // verification round's census: three cases were declining loudly here on textures + // that had simply never been glTexParameter'd. + if (record->ParamsSerial == 0) { + MGLOG_D("Texture %u has no set_texture_params record yet, so it has no built-in " + "sampler to push.", + TextureDiagName(stateTextureObject, record)); + return nullptr; + } + // D-E1: kMGPipeNullHandle in a record that WAS written is Fatal{ProtocolCorruption} + // territory - EVERY ITextureObject owns a SamplerObject, so a null is a malformed + // record and not "no sampler". The applier is where that verdict is raised; this + // side names it and declines, because a backend that sampled through whatever the + // driver texture last held would be the silent half of the same bug. + const MG_Pipe::MGPipeHandle builtin = record->Params.BuiltinSampler; + if (MG_Pipe::MGPipeHandleIsNull(builtin)) { + MGLOG_E_ONCE("MGPipe: texture %u's set_texture_params names the null handle as its " + "built-in sampler CSO; every texture owns a sampler object, so this " + "record is malformed - declining the built-in sampler push", + TextureDiagName(stateTextureObject, record)); + return nullptr; + } + const auto* cso = PipeSamplerCsoRecordForHandle(builtin); + if (cso == nullptr) { + MGLOG_E_ONCE("MGPipe: texture %u's built-in sampler CSO {%u, %u} has no applier " + "record - declining the built-in sampler push", + TextureDiagName(stateTextureObject, record), builtin.Slot, builtin.Gen); + return nullptr; + } + // THE HANDLE IS PART OF THE KEY, not just the serial. Sampler CSOs are + // content-addressed at capacity 256 (D-F1), so two textures with identical sampling + // share one CSO and a texture whose sampling diverges is re-pointed at a DIFFERENT + // CSO whose serial may well be smaller than the one it left. Keying on the serial + // alone would then skip the re-push and leave the driver texture filtering with the + // old CSO's values for ever. + // + // SamplerResync is the second half of the gate and it is the one that matters more + // than mis-filtering: ES makes a texture INCOMPLETE when its filters do not suit its + // level set, and an incomplete texture samples (0,0,0,1) rather than its contents. + // The server SETS it (RecreateBackendTexture) and the wire byte exists so the client + // can force a resync it knows about; neither side clears the other's copy (D-E2), so + // it is read here and never written back. + if (m_syncedBuiltinSampler == builtin && m_syncedBuiltinSamplerSerial == cso->Serial && + record->Params.SamplerResync == 0 && !m_forceSamplerResync) { + MGLOG_D("Sampler parameters have not changed for texture ID: %u, skipping sync.", + m_backendTextureId); + return nullptr; + } + m_syncedBuiltinSampler = builtin; + m_syncedBuiltinSamplerSerial = cso->Serial; + m_forceSamplerResync = false; + return &cso->Params; + } +#endif void BackendTextureObject::SyncBuiltinSamplerToBackend( const SharedPtr& stateTextureObject) { @@ -4675,11 +9017,62 @@ namespace MobileGL::MG_Backend::DirectGLES { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - if (!stateTextureObject) { + // P5e (tx2): NULL IS THE BY-HANDLE ARM, not an error, once a handle has been noted on + // this twin - the record and the built-in sampler's CSO answer everything below and + // the frontend object is not consulted at all. Without a handle it is still the + // caller's bug it always was. P5e (fix1): and with a handle noted on an arm that + // cannot serve it, it is Fatal{RoleViolation, "texture-handle-arm"} - see the + // refusal's own comment beside MGB_TEXTURE_HANDLE_ARM_OFF. + if (!stateTextureObject && MGB_TEXTURE_NULL_FRONTEND_REFUSED(*this, "SyncBuiltinSamplerToBackend")) { MGLOG_E_ONCE("State texture object is null, cannot sync to backend."); return; } + // P4a (D-E1): the fifteen sampler values are SAMPLER state (GL 4.6 table 23.18) and + // Espryt pushes them with glTexParameter* onto the TEXTURE rather than with + // glBindSampler onto the unit - behaviour P4a preserves exactly. What changes is + // where the values and the "did they move" answer come from. + // + // The whole arm choice is a preprocessor #if/#else rather than a runtime branch + // around one shared prologue, and that is a G1 requirement (D-P): the PULL build's + // text below the #else is the pre-P4a text token for token, so its object code + // cannot move. Routing the pull path through a pointer it did not have before was + // measured at -2 bytes on this function, which is a G1 failure. +#if MOBILEGL_PIPE_PUSH + const SamplerParameters* pendingSamplerParams = nullptr; + // P5e (tx2): resolved ONCE and shared with the target read below, which used to be + // the frontend `GetTarget()` this arm was not allowed to make. + const MG_Pipe::MGPipeResourceRecord* pushedDesc = nullptr; + if (TextureResourceSubsystemEnabled()) { + pushedDesc = ResolveOwnRecord(stateTextureObject); + pendingSamplerParams = ResolvePushedBuiltinSampler(stateTextureObject, pushedDesc); + if (pendingSamplerParams == nullptr) return; + } else { +#if !MOBILEGL_PIPE_LEGACY_MEMOS + // UNREACHABLE: ResolveTextureResourceSubsystemArm stops the process at its first + // call when the bit is clear and the pre-handle arm is not compiled. Kept, and + // kept loud, for the reason the vertex-input arm keeps its twin - this is what + // an arm resolution that ever stopped stopping would reach, and sampling through + // stale filter state is exactly what must not happen quietly. + MGLOG_E_ONCE("MGPipe: the texture-resource subsystem bit is clear and " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 removed the pre-handle built-in sampler " + "sync, so this configuration has no arm at all"); + return; +#else + auto* samplerObject = stateTextureObject->GetSamplerObject().get(); + Uint currentSamplerVersion = samplerObject->GetVersion(); + if (m_syncedSamplerVersion == currentSamplerVersion && !m_forceSamplerResync) { + MGLOG_D("Sampler parameters have not changed for texture ID: %u, skipping sync.", + m_backendTextureId); + return; + } + + m_syncedSamplerVersion = currentSamplerVersion; + m_forceSamplerResync = false; + pendingSamplerParams = &samplerObject->GetAllSamplerParameters(); +#endif + } +#else auto* samplerObject = stateTextureObject->GetSamplerObject().get(); Uint currentSamplerVersion = samplerObject->GetVersion(); if (m_syncedSamplerVersion == currentSamplerVersion && !m_forceSamplerResync) { @@ -4689,12 +9082,20 @@ namespace MobileGL::MG_Backend::DirectGLES { m_syncedSamplerVersion = currentSamplerVersion; m_forceSamplerResync = false; +#endif +#if MOBILEGL_PIPE_PUSH + MGLOG_D("Syncing texture built-in sampler with backend ID %u to backend for state ID %u", + m_backendTextureId, TextureDiagName(stateTextureObject, pushedDesc)); + auto targetInternal = MGB_TEXPARAM_TARGET(pushedDesc, stateTextureObject); + GLenum target = ConvertTextureTargetToBackendGLEnum(targetInternal); +#else MGLOG_D("Syncing texture built-in sampler with backend ID %u to backend for state ID %u", m_backendTextureId, stateTextureObject->GetExternalIndex()); GLenum target = ConvertTextureTargetToBackendGLEnum(stateTextureObject->GetTarget()); auto targetInternal = stateTextureObject->GetTarget(); +#endif MGLOG_D(" Texture target for syncing is %s", MG_Util::ConvertTextureTargetToString(targetInternal).c_str()); if (!IsSupportedTextureTarget(targetInternal)) { @@ -4703,7 +9104,11 @@ namespace MobileGL::MG_Backend::DirectGLES { return; } +#if MOBILEGL_PIPE_PUSH + const SamplerParameters& samplerParams = *pendingSamplerParams; +#else const auto& samplerParams = samplerObject->GetAllSamplerParameters(); +#endif if (TextureImpl::IsMultisampleTextureTarget(targetInternal)) { m_cacheSamplerParameters = samplerParams; return; @@ -4780,17 +9185,158 @@ namespace MobileGL::MG_Backend::DirectGLES { #undef SYNC_TEX_SAMPLER_PARAM_IF_CHANGED } +#if MOBILEGL_PIPE_PUSH + const MG_Pipe::MGPipeResourceRecord* BackendTextureObject::ResolvePushedTextureParams( + const SharedPtr& stateTextureObject) { + // P5e (tx2): the handle the caller adopted this twin with, and only failing that the + // client allocator - ResolveOwnRecord holds the whole of that rule. + const auto* record = ResolveOwnRecord(stateTextureObject); + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: texture %u has no applier record on the handle arm, so its " + "parameters cannot be pushed (handle {%u, %u})", + TextureDiagName(stateTextureObject, record), m_pushedSyncHandle.Slot, + m_pushedSyncHandle.Gen); + return nullptr; + } + // ParamsSerial + ForceResync replace m_syncedTextureParamsVersion + + // m_forceTextureParamsResync. ForceResync exists because the widened-channel + // carrier needs a swizzle override the frontend params version does not move for; + // it is SET by the server (RequireImageBindableStorage, RecreateBackendTexture set + // m_forceTextureParamsResync on the twin) and by the client on the wire, and + // NEITHER SIDE CLEARS THE OTHER'S (D-E2) - so the wire byte is read and never + // written back, while the twin's own flag is cleared here exactly as it was before. + if (m_syncedParamsSerial == record->ParamsSerial && record->Params.ForceResync == 0 && + !m_forceTextureParamsResync) { + MGLOG_D("Texture parameters have not changed for texture ID: %u, skipping sync.", + m_backendTextureId); + return nullptr; + } + m_syncedParamsSerial = record->ParamsSerial; + m_forceTextureParamsResync = false; + return record; + } +#endif + + // P4a (D-E1): the six values SyncTextureParamsToBackend pushes, spelled once per arm. + // MACROS, for the reason MGB_LEVEL_NEEDS_UPLOAD gives one function up: in the PULL build + // each expands to the pre-P4a expression at its original site, so that build's tokens - + // and its object code, whose symbol sizes G1 pins to the byte - are unchanged. Reading + // them into locals instead was measured at +9 bytes on this function. + // + // Four of the six come from MGPTextureParams, which is per texture OBJECT and + // independent of any binding - that is what closes D-E3's gap, where a texture that is + // only a READ-framebuffer attachment reaches SyncMipmapsToBackend and nothing else. The + // FORMAT comes from the descriptor beside them, and the BORDER comes from the built-in + // sampler's CSO (D-F4), because a border colour is sampler state. +#if MOBILEGL_PIPE_PUSH +#define MGB_TEXPARAM_LEVEL_RANGE \ + (pushedRecord != nullptr ? UintVec2{static_cast(pushedRecord->Params.BaseLevel), \ + static_cast(pushedRecord->Params.MaxLevel)} \ + : stateTextureObject->GetLevelRange()) +#define MGB_TEXPARAM_FORMAT \ + (pushedRecord != nullptr ? static_cast(pushedRecord->Desc.InternalFormat) \ + : stateTextureObject->GetFormat()) +#define MGB_TEXPARAM_SWIZZLE \ + (pushedRecord != nullptr \ + ? Vec4{static_cast(pushedRecord->Params.Swizzle[0]), \ + static_cast(pushedRecord->Params.Swizzle[1]), \ + static_cast(pushedRecord->Params.Swizzle[2]), \ + static_cast(pushedRecord->Params.Swizzle[3])} \ + : stateTextureObject->GetAllSwizzleParams()) +// The contract now spells this encoding (c0c, ID-12 DV-2): kMGPipeDepthStencilModeDepth = 0 = +// GL_DEPTH_COMPONENT, kMGPipeDepthStencilModeStencil = 1 = GL_STENCIL_INDEX. v1 open-coded the +// direction as a bare `!= 0` and recorded it as a deviation; the constants are read here instead, +// so a future re-numbering moves both sides at once. ZERO MEANS THE DEFAULT is still what decides +// the direction: GL_DEPTH_COMPONENT is the GL and ES default and a texture that never asks for +// the stencil aspect never emits the call, so a zeroed record has to decode to exactly what such +// a texture already has. +#define MGB_TEXPARAM_DS_MODE \ + (pushedRecord != nullptr \ + ? (pushedRecord->Params.DepthStencilMode == MG_Pipe::kMGPipeDepthStencilModeStencil ? GL_STENCIL_INDEX \ + : GL_DEPTH_COMPONENT) \ + : stateTextureObject->GetDepthStencilTextureMode()) +#define MGB_TEXPARAM_BORDER_F (pushedBorder != nullptr ? pushedBorder->borderColor : stateTextureObject->GetBorderColor()) +#define MGB_TEXPARAM_BORDER_I \ + (pushedBorder != nullptr ? pushedBorder->borderColorI : stateTextureObject->GetBorderColorI()) +#define MGB_TEXPARAM_BORDER_UI \ + (pushedBorder != nullptr ? pushedBorder->borderColorUI : stateTextureObject->GetBorderColorUI()) +#define MGB_TEXPARAM_BORDER_FORM \ + (pushedBorder != nullptr ? pushedBorder->borderColorForm : stateTextureObject->GetBorderColorForm()) +#else +#define MGB_TEXPARAM_LEVEL_RANGE stateTextureObject->GetLevelRange() +#define MGB_TEXPARAM_FORMAT stateTextureObject->GetFormat() +#define MGB_TEXPARAM_SWIZZLE stateTextureObject->GetAllSwizzleParams() +#define MGB_TEXPARAM_DS_MODE stateTextureObject->GetDepthStencilTextureMode() +#define MGB_TEXPARAM_BORDER_F stateTextureObject->GetBorderColor() +#define MGB_TEXPARAM_BORDER_I stateTextureObject->GetBorderColorI() +#define MGB_TEXPARAM_BORDER_UI stateTextureObject->GetBorderColorUI() +#define MGB_TEXPARAM_BORDER_FORM stateTextureObject->GetBorderColorForm() +#endif + void BackendTextureObject::SyncTextureParamsToBackend( const SharedPtr& stateTextureObject) { #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif - if (!stateTextureObject) { + // P5e (tx2): null IS the by-handle arm once a handle has been noted on this twin; + // see the twin note in SyncBuiltinSamplerToBackend. + if (!stateTextureObject && MGB_TEXTURE_NULL_FRONTEND_REFUSED(*this, "SyncTextureParamsToBackend")) { MGLOG_E_ONCE("State texture object is null, cannot sync to backend."); return; } + // P4a (D-E1/D-E3): where the parameter values come from, and what says they moved. + // Null on the handle arm means "nothing to do" and is NEVER a fall-back to the + // frontend; the legacy arm keeps the params-version pair verbatim, token for token, + // because the PULL build's object code may not move (D-P). +#if MOBILEGL_PIPE_PUSH + const MG_Pipe::MGPipeResourceRecord* pushedRecord = nullptr; + const SamplerParameters* pushedBorder = nullptr; + // Whether the four border comparands can be READ at all this sync. On the handle arm + // a missing sampler CSO makes them unreadable and the border block below is skipped + // with the refusal already named; on the legacy arm they always come from the + // frontend object, so it is constant true and folds away. + Bool borderColorReadable = true; + if (TextureResourceSubsystemEnabled()) { + pushedRecord = ResolvePushedTextureParams(stateTextureObject); + if (pushedRecord == nullptr) return; + // D-F4: the border colour is SAMPLER state (GL 4.6 table 23.18) and lives on the + // built-in sampler's CSO, not on MGPTextureParams - P4a deliberately does not + // put SamplerParameters on the wire twice. All FOUR comparands (float, int, + // uint, form) still cross and are still all compared below, because two integer + // borders differing above 2^24 share one float and a Float -> Int transition can + // leave every number unchanged while needing a different driver entry point. + const auto* borderCso = PipeSamplerCsoRecordForHandle(pushedRecord->Params.BuiltinSampler); + if (borderCso != nullptr) { + pushedBorder = &borderCso->Params; + } else { + borderColorReadable = false; + MGLOG_E_ONCE("MGPipe: texture %u's built-in sampler CSO {%u, %u} has no applier " + "record, so its border colour cannot be pushed", + TextureDiagName(stateTextureObject, pushedRecord), + pushedRecord->Params.BuiltinSampler.Slot, + pushedRecord->Params.BuiltinSampler.Gen); + } + } else { +#if !MOBILEGL_PIPE_LEGACY_MEMOS + // UNREACHABLE, and kept loud: see the twin note in SyncBuiltinSamplerToBackend. + MGLOG_E_ONCE("MGPipe: the texture-resource subsystem bit is clear and " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 removed the pre-handle texture-parameter " + "sync, so this configuration has no arm at all"); + return; +#else + Uint16 currentTextureParamsVersion = stateTextureObject->GetTextureParamsVersion(); + if (m_syncedTextureParamsVersion == currentTextureParamsVersion && !m_forceTextureParamsResync) { + MGLOG_D("Texture parameters have not changed for texture ID: %u, skipping sync.", + m_backendTextureId); + return; + } + m_syncedTextureParamsVersion = currentTextureParamsVersion; + m_forceTextureParamsResync = false; +#endif + } +#else Uint16 currentTextureParamsVersion = stateTextureObject->GetTextureParamsVersion(); if (m_syncedTextureParamsVersion == currentTextureParamsVersion && !m_forceTextureParamsResync) { MGLOG_D("Texture parameters have not changed for texture ID: %u, skipping sync.", m_backendTextureId); @@ -4798,12 +9344,21 @@ namespace MobileGL::MG_Backend::DirectGLES { } m_syncedTextureParamsVersion = currentTextureParamsVersion; m_forceTextureParamsResync = false; +#endif + +#if MOBILEGL_PIPE_PUSH + MGLOG_D("Syncing texture params with backend ID %u to backend for state ID %u", m_backendTextureId, + TextureDiagName(stateTextureObject, pushedRecord)); + auto targetInternal = MGB_TEXPARAM_TARGET(pushedRecord, stateTextureObject); + GLenum target = ConvertTextureTargetToBackendGLEnum(targetInternal); +#else MGLOG_D("Syncing texture params with backend ID %u to backend for state ID %u", m_backendTextureId, stateTextureObject->GetExternalIndex()); GLenum target = ConvertTextureTargetToBackendGLEnum(stateTextureObject->GetTarget()); auto targetInternal = stateTextureObject->GetTarget(); +#endif MGLOG_D(" Texture target for syncing is %s", MG_Util::ConvertTextureTargetToString(targetInternal).c_str()); if (!IsSupportedTextureTarget(targetInternal)) { @@ -4821,8 +9376,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // change detection below would swallow the very writes we came here to emit. const Bool isMultisampleTarget = TextureImpl::IsMultisampleTextureTarget(targetInternal); if (isMultisampleTarget) { - m_cacheLodRange = stateTextureObject->GetLevelRange(); + m_cacheLodRange = MGB_TEXPARAM_LEVEL_RANGE; +#if MOBILEGL_PIPE_PUSH + if (borderColorReadable) m_cacheBorderColor = MGB_TEXPARAM_BORDER_F; +#else m_cacheBorderColor = stateTextureObject->GetBorderColor(); +#endif } Bind(target); @@ -4833,7 +9392,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Update texture parameters MGLOG_D("Updating texture parameters for texture with ID: %u", m_backendTextureId); - const auto& levelRange = stateTextureObject->GetLevelRange(); + const auto& levelRange = MGB_TEXPARAM_LEVEL_RANGE; if (!isMultisampleTarget && m_cacheLodRange.x() != levelRange.x()) { g_GLESFuncs.glTexParameteri(target, GL_TEXTURE_BASE_LEVEL, static_cast(levelRange.x())); @@ -4855,8 +9414,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // whatever the draw that filled it wrote there is not what GL would report: a format // without alpha reads back as 1.0. Answer the ALPHA swizzle source with ONE so the // promotion stays invisible, composed with the swizzle the application asked for. - Vec4 swizzleParams = stateTextureObject->GetAllSwizzleParams(); - if (TextureImpl::BackendTextureFormatAddsAlpha(stateTextureObject->GetFormat(), targetInternal)) { + Vec4 swizzleParams = MGB_TEXPARAM_SWIZZLE; + if (TextureImpl::BackendTextureFormatAddsAlpha(MGB_TEXPARAM_FORMAT, targetInternal)) { for (SizeT channel = 0; channel < 4; ++channel) { if (swizzleParams[channel] == TextureSwizzleParam::Alpha) { swizzleParams[channel] = TextureSwizzleParam::One; @@ -4874,7 +9433,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // logical texel, so it is the source that is substituted, never the destination. if (const auto imageWidening = m_imageBindableStorageRequired - ? TextureImpl::GetImageBindableStorageWidening(stateTextureObject->GetFormat()) + ? TextureImpl::GetImageBindableStorageWidening(MGB_TEXPARAM_FORMAT) : TextureImpl::ImageBindableStorageWidening{}) { for (SizeT channel = 0; channel < 4; ++channel) { switch (swizzleParams[channel]) { @@ -4923,31 +9482,44 @@ namespace MobileGL::MG_Backend::DirectGLES { // float one: two integer borders that differ above 2^24 (16777216 and 16777217, say) // collapse onto the same float, so a float-only comparison would skip the second sync and // leave the driver holding the first value forever. - const auto borderColorForm = stateTextureObject->GetBorderColorForm(); - if (!isMultisampleTarget && g_GLESCapabilities.SupportsTextureBorderClamp && - (m_cacheBorderColor != stateTextureObject->GetBorderColor() || - m_cacheBorderColorI != stateTextureObject->GetBorderColorI() || - m_cacheBorderColorUI != stateTextureObject->GetBorderColorUI() || +#if MOBILEGL_PIPE_PUSH + // m-3: GATED, because on the handle arm an unreadable border means the CSO record is + // missing and the block below is skipped with the refusal already named - so v1's + // unconditional initialiser was a live frontend read on a switched-over path whose + // value nothing then used. The value a skipped block sees is the cache's own, which + // cannot make the condition true. + const auto borderColorForm = borderColorReadable ? MGB_TEXPARAM_BORDER_FORM : m_cacheBorderColorForm; +#else + const auto borderColorForm = MGB_TEXPARAM_BORDER_FORM; +#endif + if (!isMultisampleTarget && +#if MOBILEGL_PIPE_PUSH + borderColorReadable && +#endif + g_GLESCapabilities.SupportsTextureBorderClamp && + (m_cacheBorderColor != MGB_TEXPARAM_BORDER_F || + m_cacheBorderColorI != MGB_TEXPARAM_BORDER_I || + m_cacheBorderColorUI != MGB_TEXPARAM_BORDER_UI || m_cacheBorderColorForm != borderColorForm)) { if (borderColorForm == BorderColorForm::Int && g_GLESFuncs.glTexParameterIiv) { - const auto& borderColorI = stateTextureObject->GetBorderColorI(); + const auto& borderColorI = MGB_TEXPARAM_BORDER_I; const GLint borderColorArray[4] = {borderColorI.x(), borderColorI.y(), borderColorI.z(), borderColorI.w()}; g_GLESFuncs.glTexParameterIiv(target, GL_TEXTURE_BORDER_COLOR, borderColorArray); } else if (borderColorForm == BorderColorForm::Uint && g_GLESFuncs.glTexParameterIuiv) { - const auto& borderColorUI = stateTextureObject->GetBorderColorUI(); + const auto& borderColorUI = MGB_TEXPARAM_BORDER_UI; const GLuint borderColorArray[4] = {borderColorUI.x(), borderColorUI.y(), borderColorUI.z(), borderColorUI.w()}; g_GLESFuncs.glTexParameterIuiv(target, GL_TEXTURE_BORDER_COLOR, borderColorArray); } else { - const auto& borderColor = stateTextureObject->GetBorderColor(); + const auto& borderColor = MGB_TEXPARAM_BORDER_F; const GLfloat borderColorArray[4] = {borderColor.x(), borderColor.y(), borderColor.z(), borderColor.w()}; g_GLESFuncs.glTexParameterfv(target, GL_TEXTURE_BORDER_COLOR, borderColorArray); } - m_cacheBorderColor = stateTextureObject->GetBorderColor(); - m_cacheBorderColorI = stateTextureObject->GetBorderColorI(); - m_cacheBorderColorUI = stateTextureObject->GetBorderColorUI(); + m_cacheBorderColor = MGB_TEXPARAM_BORDER_F; + m_cacheBorderColorI = MGB_TEXPARAM_BORDER_I; + m_cacheBorderColorUI = MGB_TEXPARAM_BORDER_UI; m_cacheBorderColorForm = borderColorForm; DebugImpl::ErrorLopper::Loop([file = __FILE__, line = __LINE__, func = __func__](GLenum err) { MGLOG_D("%s(%s:%d) ES error %s", func, file, line, MG_Util::ConvertGLEnumToString(err).c_str()); @@ -4965,7 +9537,7 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESCapabilities.GLESVersion.Major > 3 || (g_GLESCapabilities.GLESVersion.Major == 3 && g_GLESCapabilities.GLESVersion.Minor >= 1); if (supportsStencilTextureMode) { - const GLenum depthStencilTextureMode = stateTextureObject->GetDepthStencilTextureMode(); + const GLenum depthStencilTextureMode = MGB_TEXPARAM_DS_MODE; if (m_cacheDepthStencilTextureMode != depthStencilTextureMode) { g_GLESFuncs.glTexParameteri(target, GL_DEPTH_STENCIL_TEXTURE_MODE, static_cast(depthStencilTextureMode)); @@ -4977,6 +9549,14 @@ namespace MobileGL::MG_Backend::DirectGLES { } } } +#undef MGB_TEXPARAM_LEVEL_RANGE +#undef MGB_TEXPARAM_FORMAT +#undef MGB_TEXPARAM_SWIZZLE +#undef MGB_TEXPARAM_DS_MODE +#undef MGB_TEXPARAM_BORDER_F +#undef MGB_TEXPARAM_BORDER_I +#undef MGB_TEXPARAM_BORDER_UI +#undef MGB_TEXPARAM_BORDER_FORM void ActivateTextureUnit(Uint unit) { if (unit == g_activeTextureUnit) { @@ -4999,7 +9579,29 @@ namespace MobileGL::MG_Backend::DirectGLES { Array, MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS> g_boundTexturesCache; - StateBackendObjectRegistry g_backendTextureObjects; + TwinRegistry g_backendTextureObjects; + +#if MOBILEGL_PIPE_PUSH + // P5e (id), CONTRACT-P5E §4.1. See Managers.h for the contract. + BackendTextureObject* ResolveTextureTwin(MG_Pipe::MGPipeHandle res) { + if (MG_Pipe::MGPipeHandleIsNull(res)) return nullptr; + // THE RECORD FIRST: a texture handle with no resource record is a seam defect - the + // client named a texture it never created, or one whose resource_destroy has + // already applied while a binding still names it - and a twin adopted for it would + // own a driver texture nothing ever uploads to. + if (PipeTextureRecordForHandle(res) == nullptr) { + MGLOG_E_ONCE("MGPipe: texture {%u, %u} has no applier resource record on the handle " + "arm, so no driver texture can be built for it and the unit keeps what " + "it holds", + res.Slot, res.Gen); + return nullptr; + } + auto* slot = AdoptTwinByHandle(g_backendTextureObjects, res, "texture"); + if (slot == nullptr) return nullptr; + if (!*slot) *slot = MakeShared(); + return slot->get(); + } +#endif } // namespace TextureImpl namespace FramebufferImpl { @@ -5115,6 +9717,14 @@ namespace MobileGL::MG_Backend::DirectGLES { g_fboSyncedSlotVersions = {0}; g_fboSyncedObjectVersions = {0}; g_fboSyncedObjects = {}; +#if MOBILEGL_PIPE_PUSH && MOBILEGL_ESPRYT_FBO_HANDLE_ARM_MEMOS_LINKED + // E's MAJOR-4 / A9: the handle arm's own memos say the same thing about the same + // targets, so they are invalidated HERE rather than at each of the nine call sites - + // six in DirectGLES.cpp and three in SanityTest.cpp, and it was the three that were + // missed. See the declaration in Managers.h for why the call is gated and which line + // each package flips. + InvalidateFramebufferHandleArmMemos(); +#endif } void BackendFramebufferObject::InvalidateSyncedState() { @@ -5146,6 +9756,11 @@ namespace MobileGL::MG_Backend::DirectGLES { if (attachmentObject.IsTexture()) { const auto& textureObject = attachmentObject.GetTexture(); SharedPtr backendTextureObject; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution (and the mint on + // a first attach), named debt inside the scope - P3b/P4b rekeys the registry. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif if (auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get())) { backendTextureObject = *backendTextureSlot; } else { @@ -5195,6 +9810,11 @@ namespace MobileGL::MG_Backend::DirectGLES { } } else if (attachmentObject.IsRenderbuffer()) { const auto& renderbufferObject = attachmentObject.GetRenderbuffer(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution (and the mint on + // a first attach), named debt inside the scope - P3b/P4b rekeys the registry. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif SharedPtr backendRenderbufferObject; if (auto* backendRenderbufferSlot = RenderbufferImpl::g_backendRenderbufferObjects.Find(renderbufferObject.get())) { @@ -5280,7 +9900,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // read buffer names no colour attachment at all. static const MG_State::GLState::FramebufferAttachmentObject* GetReadColorAttachment() { const auto& readFBO = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (!readFBO) { return nullptr; } @@ -5355,6 +9975,297 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } +#if MOBILEGL_PIPE_PUSH + // THE FOUR CROSS-OBJECT MASKS, ANSWERED FROM THE RECORD (ID-12 DV-5, review M-3 / DV-5). + // + // All four reduce to (internal format, TEXTURE TARGET) for a texture attachment and to + // (internal format) for a renderbuffer one, and MGPSurface now carries both inline - + // InternalFormat since D-C1 and TextureTarget since c0c widened Pad0 into it. So the + // handle arm stops reading the frontend attachment objects to compute them, which is + // what D-C1's "the four cross-object masks fall out at push time with no lookup" meant. + // + // ShouldUseCaveatTextureFormat / BackendTextureFormatAddsAlpha / their renderbuffer + // siblings are UNTOUCHED - two of them are on D-N's byte-identical list. Only what they + // are ASKED changes, which is the whole point of carrying the target rather than + // inventing a TextureUploadTarget -> TextureTarget inverse: feeding a D-N-pinned + // function a guessed input would silently change what every texture is allocated as. + // + // Kind IS the gate (c0c: "consulted only when Kind == kMGPipeSurfaceKindTexture"), and + // kMGPipeSurfaceNoTextureTarget on a texture point is a seam defect - refused loudly and + // answered false, never guessed. False is the safe direction for all four: the mask + // turns an emulation ON, and an emulation that does not run leaves the driver's own + // (correct-for-the-real-format) behaviour, while one that runs on the wrong buffer + // clamps or overwrites texels the application wrote. + static Bool PushedSurfaceTextureTarget(const MG_Pipe::MGPSurface& surface, TextureTarget* out) { + if (surface.TextureTarget == MG_Pipe::kMGPipeSurfaceNoTextureTarget) { + MGLOG_E_ONCE("MGPipe: attachment surface {%u, %u} says Kind=texture but carries no " + "texture target - refusing to guess one for the cross-object masks", + surface.Res.Slot, surface.Res.Gen); + return false; + } + *out = static_cast(surface.TextureTarget); + return true; + } + + static Bool IsSnormFallbackSurface(const MG_Pipe::MGPSurface& surface) { + const auto format = static_cast(surface.InternalFormat); + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindTexture) { + TextureTarget target = TextureTarget::Unknown; + return PushedSurfaceTextureTarget(surface, &target) && IsSnormFormat(format) && + TextureImpl::ShouldUseCaveatTextureFormat(format, target); + } + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindRenderbuffer) { + return IsSnormFormat(format) && TextureImpl::ShouldUseCaveatRenderbufferFormat(format); + } + return false; + } + + static Bool IsUnormFallbackSurface(const MG_Pipe::MGPSurface& surface) { + const auto format = static_cast(surface.InternalFormat); + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindTexture) { + TextureTarget target = TextureTarget::Unknown; + return PushedSurfaceTextureTarget(surface, &target) && IsUnormFormat(format) && + TextureImpl::ShouldUseCaveatTextureFormat(format, target); + } + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindRenderbuffer) { + return IsUnormFormat(format) && TextureImpl::ShouldUseCaveatRenderbufferFormat(format); + } + return false; + } + + static Bool IsAlphaWidenedColorSurface(const MG_Pipe::MGPSurface& surface) { + const auto format = static_cast(surface.InternalFormat); + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindTexture) { + TextureTarget target = TextureTarget::Unknown; + return PushedSurfaceTextureTarget(surface, &target) && + TextureImpl::BackendTextureFormatAddsAlpha(format, target); + } + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindRenderbuffer) { + return TextureImpl::BackendRenderbufferFormatAddsAlpha(format); + } + return false; + } + + static Bool IsIntegerColorSurface(const MG_Pipe::MGPSurface& surface) { + // No target and no caveat table: integerness is a property of the format alone, so + // this one is the same question on both arms. + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindNone) { + return false; + } + return IsIntegerColorFormat(static_cast(surface.InternalFormat)); + } +#endif + +#if MOBILEGL_PIPE_PUSH + // The record's surface for an attachment POINT, or null when this record does not + // describe that point at all. MGPFramebufferState carries Color[8] + Depth + Stencil + // (D-C1), which is every point a framebuffer can hold on the handle arm: D-C3 refuses + // bit 9 outright on a driver reporting more than 8 colour attachments, and the + // FRONT/BACK points belong to the DEFAULT framebuffer, which has no twin at all. + const MG_Pipe::MGPSurface* PushedSurfaceForAttachment(const MG_Pipe::MGPFramebufferState& record, + FramebufferAttachmentType point) { + if (point == FramebufferAttachmentType::Depth) return &record.Depth; + if (point == FramebufferAttachmentType::Stencil) return &record.Stencil; + if (point < FramebufferAttachmentType::Color0 || point > FramebufferAttachmentType::Color31) { + return nullptr; + } + const Int index = static_cast(point) - static_cast(FramebufferAttachmentType::Color0); + if (index >= static_cast(MG_Pipe::kMGPipeMaxColorAttachments)) return nullptr; + return &record.Color[static_cast(index)]; + } + + // SyncAttachmentObject's handle arm (review M-3). WHAT MOVES IS THE RESOLUTION: the twin + // is adopted from MGPSurface::Res through the slot table instead of being looked up by + // the frontend object's ADDRESS, and the attach SHAPE - layered, level, layer, the + // upload target and the texture target - is read off the surface instead of off the + // frontend attachment object. + // + // P5e (fb, CONTRACT-P5E.md §5.4): THE FRONTEND ATTACHMENT PARAMETER IS GONE. It was + // here for two things and P5e retires both. The storage syncs now have by-handle forms + // (SyncMipmapsToBackendByHandle / SyncToBackendByHandle), so the object is no longer + // needed to reach the texels; and the record is now the ONLY statement of what is + // attached, so the cross-checks had nothing left to corroborate against - a second + // answer that only a monolith build could produce is not a check, it is a second + // writer. With the object gone, review N-6's hole ("record empty, frontend still + // holding") is UNREPRESENTABLE rather than refused: the caller's detach and this + // function's attach now read the SAME test, `Kind == None || Res null`. + // + // The two P5c cross-checks that ran HandleOf on the apply thread go with it. The + // renderbuffer one (the one that was NOT transport-gated, unlike its texture sibling) + // is deleted by the rekey rather than gated, which is what CONTRACT-P5E §4.4 asks for: + // gating it would have left a client-allocator probe compiled into a path a run-ahead + // apply reaches, and there is no longer anything for it to say. + // + // P5e (fix1, ID-81 / §5.8): THE STORAGE SYNC IS ARM-SELECTED AND THE MONOLITH ARM KEEPS + // ITS FRONTEND OBJECT. This function has TWO callers and they are on opposite arms: the + // handle form (SyncToBackendByHandle) has no frontend object and must not acquire one, + // while the OBJECT form (SyncToBackend, the push-monolith arm - P4a drives its + // attachments from the record too, gated on FramebufferSubsystemEnabled() alone, D-C2) + // is holding the frontend attachment the whole time. fb dropped the parameter for both, + // which routed the monolith arm into tx2's by-handle storage seam; that seam passes a + // NULL SharedPtr and relies on the record arm being selected, and under + // Transport == Monolith it is not - `stateTextureObject->IsTextureView()` then + // dereferenced null (the device's Lightmap. -> clearColorTexture -> glClear + // SIGSEGV). So the parameter comes back as MONOLITH GLUE, used for nothing but the + // storage sync: the attach SHAPE, the empty-point test and the twin adoption stay the + // record's on both arms, which is what keeps N-6's hole closed and the cross-checks + // deleted. It is a pointer and it is null on the handle arm, so "which arm am I on" is + // never inferred from it - MG_Config::Transport decides, as §5.8 requires, and a + // monolith arm that somehow arrives without one refuses by name instead of attaching a + // texture whose levels were never pushed. + static Bool SyncAttachmentSurface(GLenum glFBOTarget, const MG_Pipe::MGPSurface& surface, + GLenum glBackendAttachment, + const MG_State::GLState::FramebufferAttachmentObject* monolithAttachment) { + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindNone || + MG_Pipe::MGPipeHandleIsNull(surface.Res)) { + // An empty point: the caller detached it on exactly this test, so attaching + // nothing here is the whole of the agreement. (The legacy arm does the same.) + return true; + } + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindTexture) { + // The record's handle is validated BEFORE the frontend is consulted (N-3): it is + // the untrusted half, the frontend cross-check below is the corroboration, and + // asking in this order is what gives the two adoption refusals a reachable + // caller at all. + if (!PipeTwinHandleIsAdoptable(TextureImpl::g_backendTextureObjects, surface.Res, "texture")) { + return false; + } + auto* twinSlot = AdoptTwinByHandle(TextureImpl::g_backendTextureObjects, surface.Res, "texture"); + if (twinSlot == nullptr) { + return false; // AdoptTwinByHandle named the refusal + } + if (!*twinSlot) { + *twinSlot = MakeShared(); + } + // COPIED OUT of the table: SyncMipmapsToBackend can grow it (a re-mint adopts + // another handle), and Managers.h:381-385's pointer-invalidation warning applies + // to exactly this sequence. + SharedPtr backendTextureObject = *twinSlot; + if (!backendTextureObject) { + MGLOG_E_ONCE("%s: No backend texture found for FBO attachment, cannot bind texture.", __func__); + return false; + } + // P5e (fb): the storage sync BY HANDLE (tx2's seam). The level shadow it reads + // is the applier's staged texture store, not a frontend object, which is what + // let the object parameter go - UNDER A TRANSPORT. P5e (fix1, ID-81): the + // push-monolith arm keeps the frontend sync it had at f6cfcbd3, token for token, + // because tx2's record arm inside SyncMipmapsToBackend is itself selected by + // `Transport != Monolith` and cannot answer here. +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + backendTextureObject->SyncMipmapsToBackendByHandle(surface.Res); + } else +#endif + { + const SharedPtr monolithTexture = + (monolithAttachment != nullptr && monolithAttachment->IsTexture()) + ? monolithAttachment->GetTexture() + : nullptr; + if (!monolithTexture) { + MGLOG_E_ONCE("MGPipe: attachment record names texture {%u, %u} on the " + "push-monolith arm, where the storage sync needs the frontend " + "texture and this caller supplied none - refusing to attach a " + "texture whose levels were never pushed", + surface.Res.Slot, surface.Res.Gen); + return false; + } + backendTextureObject->SyncMipmapsToBackend(monolithTexture); + } + const auto uploadTarget = static_cast(surface.UploadTarget); + if (surface.Layered != 0) { + g_GLESFuncs.glFramebufferTexture(glFBOTarget, glBackendAttachment, + backendTextureObject->GetBackendTextureId(), + static_cast(surface.Level)); + } else if (uploadTarget == TextureUploadTarget::Texture3D || + uploadTarget == TextureUploadTarget::Texture2DArray || + uploadTarget == TextureUploadTarget::Texture1DArray || + uploadTarget == TextureUploadTarget::CubeMapArray || + uploadTarget == TextureUploadTarget::Texture2DMultisampleArray) { + g_GLESFuncs.glFramebufferTextureLayer(glFBOTarget, glBackendAttachment, + backendTextureObject->GetBackendTextureId(), + static_cast(surface.Level), + static_cast(surface.Layer)); + } else { + auto glTextureTarget = TextureImpl::ConvertTextureUploadTargetToBackendGLEnum(uploadTarget); + if (glTextureTarget == GL_UNKNOWN_MGL) { + TextureTarget target = TextureTarget::Unknown; + if (!PushedSurfaceTextureTarget(surface, &target)) { + return false; + } + glTextureTarget = TextureImpl::ConvertTextureTargetToBackendGLEnum(target); + } + // Same cube-face rule as the legacy arm: glBindTexture rejects the face + // enums, so bind through the owning cube target and attach with the face. + const Bool isCubeFace = glTextureTarget >= GL_TEXTURE_CUBE_MAP_POSITIVE_X && + glTextureTarget <= GL_TEXTURE_CUBE_MAP_NEGATIVE_Z; + backendTextureObject->Bind(isCubeFace ? GL_TEXTURE_CUBE_MAP : glTextureTarget); + g_GLESFuncs.glFramebufferTexture2D(glFBOTarget, glBackendAttachment, glTextureTarget, + backendTextureObject->GetBackendTextureId(), + static_cast(surface.Level)); + } + return true; + } + if (surface.Kind == MG_Pipe::kMGPipeSurfaceKindRenderbuffer) { + // Same order as the texture arm above, for the same reason (N-3). + if (!PipeTwinHandleIsAdoptable(RenderbufferImpl::g_backendRenderbufferObjects, surface.Res, + "renderbuffer")) { + return false; + } + auto* twinSlot = AdoptTwinByHandle(RenderbufferImpl::g_backendRenderbufferObjects, surface.Res, + "renderbuffer"); + if (twinSlot == nullptr) { + return false; + } + if (!*twinSlot) { + *twinSlot = MakeShared(); + } + SharedPtr backendRenderbufferObject = *twinSlot; + if (!backendRenderbufferObject) { + MGLOG_E_ONCE("%s: No backend renderbuffer found for FBO attachment.", __func__); + return false; + } + // P5e (fb): the four allocation values were already record-supplied (D-D2); what + // moves here is the LOOKUP - the handle the surface carries, never HandleOf on + // an object the apply thread was handed. + // + // P5e (fix1, ID-81): arm-selected for the texture arm's reason, and here it is a + // BEHAVIOUR difference even though the by-handle body dereferences nothing: the + // object form reports a refused allocation to the application through the live + // GLContext (RecordError) and the by-handle form deliberately does not, because + // under a transport there is no application on this side to report it to. On the + // push-monolith arm there is, so the monolith arm keeps the object form. +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + backendRenderbufferObject->SyncToBackendByHandle(surface.Res); + } else +#endif + { + const SharedPtr monolithRenderbuffer = + (monolithAttachment != nullptr && monolithAttachment->IsRenderbuffer()) + ? monolithAttachment->GetRenderbuffer() + : nullptr; + if (!monolithRenderbuffer) { + MGLOG_E_ONCE("MGPipe: attachment record names renderbuffer {%u, %u} on the " + "push-monolith arm, where the storage allocation needs the " + "frontend renderbuffer and this caller supplied none - refusing " + "to attach a renderbuffer with no storage", + surface.Res.Slot, surface.Res.Gen); + return false; + } + backendRenderbufferObject->SyncToBackend(monolithRenderbuffer); + } + backendRenderbufferObject->Bind(); + g_GLESFuncs.glFramebufferRenderbuffer(glFBOTarget, glBackendAttachment, GL_RENDERBUFFER, + backendRenderbufferObject->GetBackendRenderbufferId()); + return true; + } + MGLOG_E_ONCE("MGPipe: attachment surface {%u, %u} carries Kind=%u, which is neither a texture " + "nor a renderbuffer nor an empty point - refusing to attach", + surface.Res.Slot, surface.Res.Gen, surface.Kind); + return false; + } +#endif + Uint32 ComputeAlphaWidenedDrawBufferMask(const MG_State::GLState::FramebufferObject& fbo) { using FBO = MG_State::GLState::FramebufferObject; const auto& drawBuffers = fbo.GetDrawBuffers(); @@ -5385,7 +10296,7 @@ namespace MobileGL::MG_Backend::DirectGLES { Bool IsFixedPointFallbackReadAttachment() { const auto& readFBO = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (!readFBO) { return false; } @@ -5408,11 +10319,175 @@ namespace MobileGL::MG_Backend::DirectGLES { return false; } +#if MOBILEGL_PIPE_PUSH + const MG_Pipe::MGPFramebufferState* PushedFramebufferRecord(MG_Pipe::MGPipeHandle fbo) { + // FramebufferRecordFor answers null for the null handle and for a slot nothing has + // described (both silent - that is every framebuffer before its first emission), and + // refuses a stale generation loudly while counting it in + // StaleFramebufferRecordLookups. So there is no second emptiness test here to get out + // of step with the applier's, and none of the three cases is a binding question. + return MG_Pipe::MGPipeApplier().FramebufferRecordFor(fbo); + } + + Bool PushedFramebufferIsBoundTo(FramebufferTarget target, MG_Pipe::MGPipeHandle fbo) { + if (MG_Pipe::MGPipeHandleIsNull(fbo)) return false; + const auto binding = target == FramebufferTarget::Read ? MG_Pipe::MGPipeFramebufferTarget::Read + : MG_Pipe::MGPipeFramebufferTarget::Draw; + return MG_Pipe::MGPipeApplier().BoundFramebuffer[static_cast(binding)] == fbo; + } + + // The record's DrawBuffers[] is an ATTACHMENT INDEX with -1 for None (D-C1), which is + // what the frontend array holds for every framebuffer that has a twin. It deliberately + // cannot spell FrontLeft / FrontRight / BackLeft / BackRight: those are the DEFAULT + // framebuffer's own tokens, the default framebuffer has no BackendFramebufferObject at + // all (it is pDefaultFramebufferInfo->defaultFBO, which is why MGPipeHandles.h reserves + // {0,1} for it), and the legacy arm's branch for them says so in as many words - "shouldn't + // remap". So the conversion below is total for every object that reaches this twin, and + // the record's IsDefault is the thing a later phase would test if that ever changed. + static void DecodePushedDrawBuffers(const MG_Pipe::MGPFramebufferState& record, + FramebufferAttachmentType* out) { + for (Uint i = 0; i < MG_State::GLState::FramebufferObject::MAX_DRAW_BUFFERS; ++i) { + const Int8 index = record.DrawBuffers[i]; + out[i] = index < 0 ? FramebufferAttachmentType::None + : static_cast( + static_cast(FramebufferAttachmentType::Color0) + index); + } + } +#endif + +#if MOBILEGL_PIPE_PUSH + // P5e (fb, §5.4): the record half of SyncReadBufferToBackend, lifted out of it so the + // by-handle sync can run it WITHOUT a frontend object. Nothing inside reads one: the + // frontend index was only ever a diagnostic, and it is passed as a number now + // (GlNameForDiag is 0 on the handle arm, which reads as "the record did not say"). + void BackendFramebufferObject::ApplyReadBufferFromRecord(const MG_Pipe::MGPFramebufferState& record, + Uint glNameForDiag) { + // D-C1 makes the NULL Res the spelling of "no attachment" and c0c's + // kMGPipeSurfaceKindNone makes Kind agree with it on a zero-initialised record. + // Res stays the test - it is the one D-C1 fixes - and Kind is CROSS-CHECKED + // rather than consulted: the two are one statement the emitter has to make + // twice, and a record where they disagree is a seam defect, not an empty point. + // Picking a winner silently is what this refuses to do. + const Bool readSurfaceEmpty = MG_Pipe::MGPipeHandleIsNull(record.ReadSurface.Res); + if (readSurfaceEmpty != (record.ReadSurface.Kind == MG_Pipe::kMGPipeSurfaceKindNone)) { + MGLOG_E_ONCE("MGPipe: framebuffer %u's resolved read surface says Res={%u, %u} and " + "Kind=%u, which disagree about whether it names anything - refusing " + "rather than choosing one of them", + glNameForDiag, record.ReadSurface.Res.Slot, record.ReadSurface.Res.Gen, + record.ReadSurface.Kind); + return; + } + FramebufferAttachmentType pushedReadBuf = FramebufferAttachmentType::None; + if (!readSurfaceEmpty) { + // EVERY FIELD THAT IDENTIFIES THE IMAGE, not the three v1 compared (m-4). + // The same image legally sits at two colour points - a layered attachment of + // one array and a single slice of it are different surfaces of one Res - so a + // partial comparison resolves to the lower index and the refusal branch below + // ("refusing to guess") would then be doing part of the guessing itself. + // InternalFormat and TextureTarget are properties of the RESOURCE rather than + // of the point, so they add nothing to the identity and are left out. + Bool matched = false; + for (Uint i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) { + const auto& color = record.Color[i]; + if (color.Res == record.ReadSurface.Res && color.Kind == record.ReadSurface.Kind && + color.Layered == record.ReadSurface.Layered && + color.Level == record.ReadSurface.Level && color.Layer == record.ReadSurface.Layer && + color.UploadTarget == record.ReadSurface.UploadTarget) { + pushedReadBuf = static_cast( + static_cast(FramebufferAttachmentType::Color0) + static_cast(i)); + matched = true; + break; + } + } + if (!matched) { + MGLOG_E_ONCE("MGPipe: framebuffer %u's resolved read surface {%u, %u} matches no " + "colour attachment of its own record - refusing to guess a read " + "buffer", + glNameForDiag, record.ReadSurface.Res.Slot, record.ReadSurface.Res.Gen); + return; + } + } + if (pushedReadBuf == m_frontendReadBuffer) { + return; + } + m_frontendReadBuffer = pushedReadBuf; + + const GLenum glPushedReadBuffer = GetBackendAttachmentType(pushedReadBuf); + if (m_backendReadBuffer != glPushedReadBuffer) { + m_backendReadBuffer = glPushedReadBuffer; + // Still bound as READ first, for the reason the legacy arm gives below: + // glReadBuffer targets whatever FBO is bound to GL_READ_FRAMEBUFFER. + Bind(FramebufferTarget::Read); + g_GLESFuncs.glReadBuffer(glPushedReadBuffer); + } + } + + // P5e (fb): the by-handle entry, for SyncCurrentFBOByRecord's "one object on both + // bindings" skip - the one path that applies a read buffer without doing the rest of + // the sync. A framebuffer with no record is the caller's seam defect, not a silent + // no-op, so it says so. + void BackendFramebufferObject::SyncReadBufferToBackendByHandle(MG_Pipe::MGPipeHandle fbo) { + const auto* record = PushedFramebufferRecord(fbo); + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: framebuffer handle {%u, %u} has no applier record, so its read " + "buffer cannot be pushed", + fbo.Slot, fbo.Gen); + return; + } + ApplyReadBufferFromRecord(*record, 0); + } +#endif // MOBILEGL_PIPE_PUSH + void BackendFramebufferObject::SyncReadBufferToBackend( const SharedPtr& stateFBOObject) { if (!stateFBOObject) { return; } +#if MOBILEGL_PIPE_PUSH + // P4a (D-C2): the READ buffer is answered from the RESOLVED read surface of the READ + // framebuffer's own record, which is what structurally closes the read-buffer + // shared-FBO defect class. The comment two lines below - "when this is reached from + // SyncCurrentFBO's 'same FBO as draw' skip path" - describes a hazard that becomes + // unrepresentable: the record says which target it is, and ReadSurface was resolved + // from the read framebuffer's own read buffer before it ever crossed. + // + // The record carries the resolved SURFACE and not an index, so the attachment POINT + // is recovered by matching it against Color[] - the one place both spellings sit + // side by side. An empty surface (Res == the null handle, which D-C1 makes the + // spelling of "no attachment" independently of Kind's numbering) is + // FramebufferAttachmentType::None, i.e. GL_NONE. A surface that matches no colour + // point cannot be a legal read buffer and is refused rather than guessed. + // + // P5e (fb): THAT DECISION now lives in ApplyReadBufferFromRecord, which takes no + // frontend object; what is left in this overload is the monolith glue that finds + // the handle by identity. Under an active transport nothing reaches it - + // SyncToBackendByHandle runs the record form directly. + if (FramebufferSubsystemEnabled()) { + // P5c (hd): with an active transport the handle is the caller's + // (m_pushedSyncHandle), never the client allocator's - see SyncToBackend. + const MG_Pipe::MGPipeHandle fbo = +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? m_pushedSyncHandle + : +#endif + g_backendFramebufferObjects.HandleOf(stateFBOObject.get()); + // ID-19: the OBJECT's record, not "the record of whatever is bound to READ". A + // framebuffer whose read buffer is being pushed need not be the read binding at + // all - glNamedFramebufferReadBuffer and the DSA clears reach here by name - and + // ReadSurface is resolved from THIS framebuffer's own read buffer under every + // Target, Named included (c0e's MGPFramebufferState comment). + const auto* record = PushedFramebufferRecord(fbo); + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: framebuffer %u has no applier record on the handle arm, so " + "its read buffer cannot be pushed (handle {%u, %u})", + stateFBOObject->GetExternalIndex(), fbo.Slot, fbo.Gen); + return; + } + ApplyReadBufferFromRecord(*record, stateFBOObject->GetExternalIndex()); + return; + } +#endif auto frontendReadBuf = stateFBOObject->GetReadBuffer(); if (frontendReadBuf == m_frontendReadBuffer) { return; @@ -5531,11 +10606,73 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("Syncing FBO with backend ID %u to backend for state ID %u, as %s FBO", m_backendFBOId, stateFBOObject->GetExternalIndex(), (asTarget == FramebufferTarget::Draw ? "DRAW" : "READ")); GLenum glFBOTarget = MG_Util::ConvertFramebufferTargetToGLEnum(asTarget); + +#if MOBILEGL_PIPE_PUSH + // P4a (D-C2 as corrected by ID-19): THE RECORD OF THIS FRAMEBUFFER OBJECT, resolved + // BEFORE the driver framebuffer is bound. + // + // The order is the whole of C-1's fix. v1 bound first and refused afterwards, so a + // glClearNamedFramebufferfv / glBlitNamedFramebuffer on a framebuffer that is bound + // to neither target left this twin's freshly minted driver FBO bound with NO + // ATTACHMENTS and the caller then issued the clear or the blit against it - + // GL_INVALID_FRAMEBUFFER_OPERATION and nothing cleared, where the legacy arm cleared + // correctly. What the reorder buys is exactly this and no more: THE REFUSAL ITSELF + // HAS NO DRIVER EFFECT - it no longer mints and binds a driver FBO as a side effect + // of declining. It does NOT decide the driver's binding (review N-1): every caller + // binds immediately afterwards and none of them looks at a return value - + // SyncAndBindFramebufferObject is SyncToBackend then an unconditional Bind(target) + // (DirectGLES.cpp:3292-3293, the path all five DSA entry points take), and + // BindCurrentFBO binds the current FBO's twin whether or not SyncCurrentFBO synced + // it. So an unconfigured framebuffer can still end up bound; what the log line + // promises is only that this function did not put it there. + // + // The record is per FRAMEBUFFER OBJECT and is found by handle, so it describes this + // object whether it is bound to Draw, to Read, to both or to neither, and its own + // Target is NOT compared against `asTarget`: a Named record legitimately names no + // binding at all. What is still gated on `asTarget` is what reaches the DRIVER's + // bound target - glDrawBuffers and the four cross-object masks below, which stay + // Draw-only for the reason the OIT comment gives. + const MG_Pipe::MGPFramebufferState* pushedRecord = nullptr; + FramebufferObject::FramebufferAttachmentArray pushedDrawBuffers{}; + if (FramebufferSubsystemEnabled()) { + // MONOLITH GLUE, named as such: the Framebuffer handle of an object this backend + // still arrives holding. A framebuffer has a handle but NO wire lifetime (D-I2) - + // there is no framebuffer create or destroy in the catalogue and none is invented + // - so the handle exists purely to key set_framebuffer_state, which is exactly + // what it is used for here. + // + // P5c (hd): with an active transport the handle is the one the caller is applying + // (m_pushedSyncHandle - the record-driven sync set it), and the client allocator + // is never probed (T2). A null there means a caller reached this arm without a + // record, which the null-record refusal below names. + const MG_Pipe::MGPipeHandle fbo = +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? m_pushedSyncHandle + : +#endif + g_backendFramebufferObjects.HandleOf(stateFBOObject.get()); + pushedRecord = PushedFramebufferRecord(fbo); + if (pushedRecord == nullptr) { + MGLOG_E_ONCE("MGPipe: framebuffer %u has no applier record on the handle arm, so it " + "is not configured here; nothing is bound by this refusal, but the " + "caller's own bind still targets it (handle {%u, %u})", + stateFBOObject->GetExternalIndex(), fbo.Slot, fbo.Gen); + return; + } + DecodePushedDrawBuffers(*pushedRecord, pushedDrawBuffers.data()); + } +#endif Bind(asTarget); // -------------------- Connect attachments (set buffers) ----------------------- // 1. Remap draw buffers +#if MOBILEGL_PIPE_PUSH + const FramebufferObject::FramebufferAttachmentArray& stateDrawBuffers = + pushedRecord != nullptr ? pushedDrawBuffers : stateFBOObject->GetDrawBuffers(); +#else auto& stateDrawBuffers = stateFBOObject->GetDrawBuffers(); +#endif Bool drawBufferClean = false; if (memcmp(m_frontendDrawBuffers, stateDrawBuffers.data(), FramebufferObject::MAX_DRAW_BUFFERS * sizeof(FramebufferAttachmentType)) == 0) { @@ -5596,6 +10733,39 @@ namespace MobileGL::MG_Backend::DirectGLES { frontendBuf > FramebufferAttachmentType::Color31) { continue; } +#if MOBILEGL_PIPE_PUSH + // ID-12 DV-5 / M-3: on the handle arm the four masks are answered from the + // record's own surface for the point this draw buffer names, and the frontend + // attachment object below is never touched - the whole arm returns here. + // A draw buffer that names a point the record cannot describe is D-C3's + // refusal arriving too late to refuse, so it is loud and contributes no bit. + if (pushedRecord != nullptr) { + const MG_Pipe::MGPSurface* pushedSurface = + PushedSurfaceForAttachment(*pushedRecord, frontendBuf); + if (pushedSurface == nullptr) { + MGLOG_E_ONCE("MGPipe: framebuffer %u's draw buffer %u names colour point %d, " + "which its record does not describe (the record carries %u " + "colour points) - contributing no fallback mask bit", + stateFBOObject->GetExternalIndex(), i, + static_cast(frontendBuf) - + static_cast(FramebufferAttachmentType::Color0), + MG_Pipe::kMGPipeMaxColorAttachments); + continue; + } + if (IsSnormFallbackSurface(*pushedSurface)) { + snormClampOutputMask |= (1u << i); + } else if (IsUnormFallbackSurface(*pushedSurface)) { + unormClampOutputMask |= (1u << i); + } + if (IsAlphaWidenedColorSurface(*pushedSurface)) { + alphaWidenedMask |= (1u << i); + } + if (IsIntegerColorSurface(*pushedSurface)) { + integerColorMask |= (1u << i); + } + continue; + } +#endif const auto& attachmentObject = stateFBOObject->GetAttachment(frontendBuf); if (IsSnormFallbackAttachment(attachmentObject)) { snormClampOutputMask |= (1u << i); @@ -5635,6 +10805,34 @@ namespace MobileGL::MG_Backend::DirectGLES { static_cast(~0u)); m_syncedBackendIdGeneration = g_attachmentBackendIdGeneration; } +#if MOBILEGL_PIPE_PUSH + // P4a (D-C4): the record's ContentHash is what says this framebuffer's resolved state + // moved, and it REPLACES the frontend attachment versions AS A KEY. It covers every + // field the record carries, so an attachment set that moved, a draw-buffer array + // that moved, an extent that moved and a recycled Fbo whose successor happens to + // carry an identical attachment set are all one compare - and it moves in ONE place + // rather than in an array of 41 the twin had to walk. + // + // The re-arm is expressed through m_syncedFrontendAttachmentVersions rather than + // around it, so the attachment loop below is unchanged on both arms: a moved record + // arms every point and the loop then does exactly what it does today, including the + // empty-colour-point detach that keeps m_backendColorSlots a permutation of the + // PHYSICAL layout. + // + // OVER-FIRING IS FREE AND UNDER-FIRING IS FATAL, so the per-attachment versions stay + // as a second, narrower gate underneath: a frontend attachment that moved without + // the record moving still re-attaches. That direction is the safe one and it is the + // reason the array is re-armed rather than retired here. It is what the array is FOR + // now: the walk below resolves each attachment from the record (SyncAttachmentSurface), + // so the versions no longer say WHICH object a point holds - they say only "this point + // may have moved since I last looked", and they retire with the frontend attachment + // array itself, which is a later phase's. + if (pushedRecord != nullptr && m_syncedRecordHashes[SizeT(asTarget)] != pushedRecord->ContentHash) { + std::fill(m_syncedFrontendAttachmentVersions.begin(), m_syncedFrontendAttachmentVersions.end(), + static_cast(~0u)); + m_syncedRecordHashes[SizeT(asTarget)] = pushedRecord->ContentHash; + } +#endif const auto& attachments = stateFBOObject->GetAllAttachmentObjects(); const auto& attachmentVersions = stateFBOObject->GetAllFramebufferAttachmentVersions(); for (SizeT i = 0; i < attachments.size(); ++i) { @@ -5662,12 +10860,66 @@ namespace MobileGL::MG_Backend::DirectGLES { frontendType <= FramebufferAttachmentType::Color31 && (static_cast(frontendType) - static_cast(FramebufferAttachmentType::Color0)) < g_GLESCapabilities.MaxColorAttachments; - if (isColorPoint && attachmentObject.IsEmpty() && glBackendAttachment != GL_NONE) { +#if MOBILEGL_PIPE_PUSH + // P5e (fb, §5.4): ON THE RECORD ARM THE EMPTY POINT IS THE RECORD'S, and it + // is the SAME test SyncAttachmentSurface attaches on - `Kind == None || + // Res null`. Asking the frontend here and the record there is what left + // review N-6's one-directional hole; one test closes it by construction. + const Bool pointIsEmpty = + pushedRecord != nullptr + ? [&] { + const MG_Pipe::MGPSurface* s = + PushedSurfaceForAttachment(*pushedRecord, frontendType); + return s == nullptr || s->Kind == MG_Pipe::kMGPipeSurfaceKindNone || + MG_Pipe::MGPipeHandleIsNull(s->Res); + }() + : attachmentObject.IsEmpty(); +#else + const Bool pointIsEmpty = attachmentObject.IsEmpty(); +#endif + if (isColorPoint && pointIsEmpty && glBackendAttachment != GL_NONE) { g_GLESFuncs.glFramebufferRenderbuffer(glFBOTarget, glBackendAttachment, GL_RENDERBUFFER, 0); } +#if MOBILEGL_PIPE_PUSH + // M-3: the ATTACHMENT RESOLUTION is the record's on the handle arm. The walk + // itself still runs over the frontend's 41 points, because that is what says + // which points EXIST and it is also what carries the per-attachment version + // memo underneath the record hash; what the record answers is WHICH object + // each point holds and in what shape. + // + // A point the record does not describe (Color8..Color31; the FRONT/BACK + // tokens) is only reachable with a non-empty attachment if D-C3's refusal + // failed to fire, so an empty one is silently fine and a live one is loud. + Bool attachmentSynced; + if (pushedRecord != nullptr) { + const MG_Pipe::MGPSurface* pushedSurface = + PushedSurfaceForAttachment(*pushedRecord, frontendType); + if (pushedSurface == nullptr) { + if (attachmentObject.IsEmpty()) { + attachmentSynced = true; + } else { + MGLOG_E_ONCE("MGPipe: framebuffer %u holds an attachment at point %s, which " + "its record does not describe - refusing to attach it", + stateFBOObject->GetExternalIndex(), + MG_Util::ConvertFramebufferAttachmentTypeToString(frontendType).c_str()); + attachmentSynced = false; + } + } else { + attachmentSynced = + SyncAttachmentSurface(glFBOTarget, *pushedSurface, glBackendAttachment, + &attachmentObject); + } + } else { + attachmentSynced = SyncAttachmentObject(glFBOTarget, attachmentObject, glBackendAttachment); + } + if (attachmentSynced) { + m_syncedFrontendAttachmentVersions[i] = attachmentVersions[i]; + } +#else if (SyncAttachmentObject(glFBOTarget, attachmentObject, glBackendAttachment)) { m_syncedFrontendAttachmentVersions[i] = attachmentVersions[i]; } +#endif } #if MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG else { @@ -5695,6 +10947,11 @@ namespace MobileGL::MG_Backend::DirectGLES { // Verify that the backend object's name and parameters match the frontend attachment state if (attachmentObject.IsTexture()) { const auto& textureObject = attachmentObject.GetTexture(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named + // debt inside the scope - P3b/P4b rekeys the registry. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif auto* backendTextureSlot = TextureImpl::g_backendTextureObjects.Find(textureObject.get()); MOBILEGL_ASSERT(backendTextureSlot != nullptr && *backendTextureSlot != nullptr, "No backend texture found while framebuffer reports texture attachment."); @@ -5704,38 +10961,294 @@ namespace MobileGL::MG_Backend::DirectGLES { "(%d), frontend texture object ID=%d.", objectName, backendTexId, textureObject->GetExternalIndex()); - GLint texLevel = 0; - g_GLESFuncs.glGetFramebufferAttachmentParameteriv( - glFBOTarget, glBackendAttachment, GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL, &texLevel); - MOBILEGL_ASSERT(texLevel == static_cast(attachmentObject.GetTextureLevel()), - "Attachment texture level mismatch between GLES and state object."); - } else if (attachmentObject.IsRenderbuffer()) { - const auto& renderbufferObject = attachmentObject.GetRenderbuffer(); - auto* backendRboSlot = - RenderbufferImpl::g_backendRenderbufferObjects.Find(renderbufferObject.get()); - MOBILEGL_ASSERT( - backendRboSlot != nullptr && *backendRboSlot != nullptr, - "No backend renderbuffer found while framebuffer reports renderbuffer attachment."); - GLuint backendRboId = (*backendRboSlot)->GetBackendRenderbufferId(); - MOBILEGL_ASSERT(static_cast(backendRboId) == objectName, - "Attachment renderbuffer name mismatch between GLES and state object."); + GLint texLevel = 0; + g_GLESFuncs.glGetFramebufferAttachmentParameteriv( + glFBOTarget, glBackendAttachment, GL_FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL, &texLevel); + MOBILEGL_ASSERT(texLevel == static_cast(attachmentObject.GetTextureLevel()), + "Attachment texture level mismatch between GLES and state object."); + } else if (attachmentObject.IsRenderbuffer()) { + const auto& renderbufferObject = attachmentObject.GetRenderbuffer(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): frontend-keyed twin resolution, named + // debt inside the scope - P3b/P4b rekeys the registry. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + auto* backendRboSlot = + RenderbufferImpl::g_backendRenderbufferObjects.Find(renderbufferObject.get()); + MOBILEGL_ASSERT( + backendRboSlot != nullptr && *backendRboSlot != nullptr, + "No backend renderbuffer found while framebuffer reports renderbuffer attachment."); + GLuint backendRboId = (*backendRboSlot)->GetBackendRenderbufferId(); + MOBILEGL_ASSERT(static_cast(backendRboId) == objectName, + "Attachment renderbuffer name mismatch between GLES and state object."); + } + } +#endif + } + + // The walk itself can re-mint an id (SyncAttachmentObject -> + // SyncMipmapsToBackend -> RecreateBackendTexture), invalidating points this + // walk already attached or version-skipped - e.g. one texture attached at two + // points. Re-enter until the generation is quiescent: every pass syncs each + // dirty texture clean, so each repeat finds strictly fewer re-mints and the + // common case (no re-mint) never takes a second pass. The head's draw/read- + // buffer syncs are memoized against their own shadows, so a repeat re-walks + // only the attachments. + if (m_syncedBackendIdGeneration != g_attachmentBackendIdGeneration) { + SyncToBackend(stateFBOObject, asTarget); + } + } + +#if MOBILEGL_PIPE_PUSH + // ---- P5e (fb, CONTRACT-P5E.md §5.4): the reverse index, texture -> framebuffers ------ + // + // WHY IT EXISTS. ScopedDetachedTextureFramebufferAttachments used to answer "which + // framebuffers currently have this texture attached" by walking every live twin and + // reading each one's FRONTEND attachment array. Under P5e that walk is the last place + // the server holds a frontend framebuffer across records - id re-typed ForEachLive and + // left the StateForHandle read greppable, naming this package as the one that replaces + // it. The records already carry the answer: every applied set_framebuffer_state names + // its eleven surfaces, so the relation is maintained where the record is consumed. + // + // KEYED ON THE TEXTURE SLOT rather than on {slot, gen}. The question a detach asks is + // "does the driver have this texture id hanging off some framebuffer", the points are + // re-derived from the record on every hit, and a stale entry left by a recycled slot + // therefore costs one record walk that matches nothing. The FRAMEBUFFER handles are + // stored whole, so a recycled framebuffer slot never answers for its predecessor + // (FindByHandle refuses a moved Gen). + // + // MAINTAINED AT THE SYNC, NOT AT THE APPLY, and that is the same set rather than a + // smaller one: a framebuffer whose record was never synced has no driver attachment to + // detach, and every path that can have one runs this sync first. + namespace { + UnorderedMap> g_framebuffersByAttachedTextureSlot; + UnorderedMap> g_attachedTextureSlotsByFramebufferSlot; + + void ForgetFramebufferTextureAttachmentsLocked(Uint32 fboSlot) { + auto it = g_attachedTextureSlotsByFramebufferSlot.find(fboSlot); + if (it == g_attachedTextureSlotsByFramebufferSlot.end()) return; + for (const Uint32 textureSlot : it->second) { + auto listIt = g_framebuffersByAttachedTextureSlot.find(textureSlot); + if (listIt == g_framebuffersByAttachedTextureSlot.end()) continue; + auto& list = listIt->second; + list.erase(std::remove_if(list.begin(), list.end(), + [&](MG_Pipe::MGPipeHandle h) { return h.Slot == fboSlot; }), + list.end()); + if (list.empty()) g_framebuffersByAttachedTextureSlot.erase(listIt); + } + g_attachedTextureSlotsByFramebufferSlot.erase(it); + } + } // namespace + + void NoteFramebufferTextureAttachments(MG_Pipe::MGPipeHandle fbo, + const MG_Pipe::MGPFramebufferState& record) { + if (MG_Pipe::MGPipeHandleIsNull(fbo)) return; + ForgetFramebufferTextureAttachmentsLocked(fbo.Slot); + Vector textureSlots; + const auto note = [&](const MG_Pipe::MGPSurface& surface) { + if (surface.Kind != MG_Pipe::kMGPipeSurfaceKindTexture) return; + if (MG_Pipe::MGPipeHandleIsNull(surface.Res)) return; + if (std::find(textureSlots.begin(), textureSlots.end(), surface.Res.Slot) != textureSlots.end()) { + return; // one texture at two points is one entry + } + textureSlots.push_back(surface.Res.Slot); + g_framebuffersByAttachedTextureSlot[surface.Res.Slot].push_back(fbo); + }; + for (Uint i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) note(record.Color[i]); + note(record.Depth); + note(record.Stencil); + if (textureSlots.empty()) return; // the common Minecraft frame: renderbuffers only + g_attachedTextureSlotsByFramebufferSlot[fbo.Slot] = std::move(textureSlots); + } + + // BY VALUE, and deliberately: the caller re-binds framebuffers while it walks the + // answer, and anything that syncs one re-enters NoteFramebufferTextureAttachments and + // can rehash the map underneath an iterator. + // + // NOTHING PRUNES A DEAD FRAMEBUFFER'S ENTRY and nothing needs to. A stale handle is + // refused by FindByHandle (its Gen has moved), so it contributes nothing; and the + // moment the slot is reused, the successor's first sync clears its predecessor's rows + // here. The index is therefore bounded by the live framebuffer slot count, which is + // the same bound the twin table itself carries. + Vector FramebuffersAttachingTexture(MG_Pipe::MGPipeHandle texture) { + if (MG_Pipe::MGPipeHandleIsNull(texture)) return {}; + auto it = g_framebuffersByAttachedTextureSlot.find(texture.Slot); + if (it == g_framebuffersByAttachedTextureSlot.end()) return {}; + return it->second; + } + + // ---- P5e (fb, §5.4): BackendFramebufferObject::SyncToBackend BY HANDLE --------------- + // + // The record's ELEVEN SURFACES ARE THE POINT SET. The object form walks the frontend's + // 41 attachment points to learn which exist; this one does not have to, because the + // emitter REFUSES a framebuffer holding a point at or above the wire width + // (FramebufferEmit.h's two refusals) and the FRONT/BACK tokens belong to the default + // framebuffer, which has no twin. So Color[0..7] + Depth + Stencil is the whole of it, + // and a framebuffer that could not be described that way never produced a record at all. + // + // THE PER-POINT VERSION MEMO IS GONE and is not replaced. On the object form it is a + // second, narrower gate under the record hash, hedging against "a frontend attachment + // moved without the record moving" - which is unrepresentable once the record is the + // only statement of what is attached (the hash covers every surface, and Fbo is inside + // it, so a recycled handle cannot be suppressed against its predecessor). Keeping it + // would have meant keeping the frontend array that carries it, which is the whole point + // of this arm. The two SERVER-owned gates stay, because no client-side value can answer + // what they answer: g_attachmentBackendIdGeneration ("did I re-mint a driver id") and + // the re-entry it drives. + void BackendFramebufferObject::SyncToBackendByHandle(MG_Pipe::MGPipeHandle fbo, + FramebufferTarget asTarget) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + const MG_Pipe::MGPFramebufferState* const recordPtr = PushedFramebufferRecord(fbo); + if (recordPtr == nullptr) { + MGLOG_E_ONCE("MGPipe: framebuffer handle {%u, %u} has no applier record, so it is not " + "configured here; nothing is bound by this refusal, but the caller's own " + "bind still targets it", + fbo.Slot, fbo.Gen); + return; + } + const MG_Pipe::MGPFramebufferState& record = *recordPtr; + if (record.IsDefault != 0) { + // The default framebuffer has no twin (pDefaultFramebufferInfo->defaultFBO is the + // frontend object and MGPipeHandles.h reserves {0,1} for it), so reaching this + // body with one is a caller that skipped its own IsDefault branch. + MGLOG_E_ONCE("MGPipe: framebuffer handle {%u, %u} describes the DEFAULT framebuffer, " + "which has no twin to configure", + fbo.Slot, fbo.Gen); + return; + } +#if MOBILEGL_BUILD_DISAGGREGATED + // Kept in step for the monolith-glue overloads that still resolve their record + // through it (SyncReadBufferToBackend's push arm). + m_pushedSyncHandle = fbo; +#endif + const GLenum glFBOTarget = MG_Util::ConvertFramebufferTargetToGLEnum(asTarget); + Bind(asTarget); + + FramebufferObject::FramebufferAttachmentArray stateDrawBuffers{}; + DecodePushedDrawBuffers(record, stateDrawBuffers.data()); + + // 1. Remap draw buffers. Same rule as the object form: glDrawBuffers writes the + // DRAW-bound framebuffer's state, so a READ-only sync must neither issue it nor + // stamp the memo (the Minecraft 26.x OIT bug the object form names). + Bool attachmentPointsMoved = false; + const Bool drawBufferClean = + memcmp(m_frontendDrawBuffers, stateDrawBuffers.data(), + FramebufferObject::MAX_DRAW_BUFFERS * sizeof(FramebufferAttachmentType)) == 0; + if (!drawBufferClean && asTarget == FramebufferTarget::Draw) { + memcpy(m_frontendDrawBuffers, stateDrawBuffers.data(), + FramebufferObject::MAX_DRAW_BUFFERS * sizeof(FramebufferAttachmentType)); + std::fill(m_backendDrawBuffers, m_backendDrawBuffers + FramebufferObject::MAX_DRAW_BUFFERS, GL_NONE); + int nEffectiveBuffers = 0; + for (GLint i = 0; i < FramebufferObject::MAX_DRAW_BUFFERS; ++i) { + const auto frontendBuf = stateDrawBuffers[i]; + if (frontendBuf == FramebufferAttachmentType::None) { + m_backendDrawBuffers[i] = GL_NONE; + continue; + } + // DecodePushedDrawBuffers cannot produce a FRONT/BACK token - those are the + // default framebuffer's own and it has no twin - so the object form's + // "shouldn't remap" branch has no counterpart here. + m_backendDrawBuffers[i] = GL_COLOR_ATTACHMENT0 + i; + nEffectiveBuffers = i + 1; + } + g_GLESFuncs.glDrawBuffers(nEffectiveBuffers, m_backendDrawBuffers); + if (RecomputeBackendColorSlots(stateDrawBuffers)) { + m_frontendReadBuffer = FramebufferAttachmentType::Unknown; + attachmentPointsMoved = true; + } + } + + // The four cross-object masks, off the record's own surfaces (ID-12 DV-5). + if (asTarget == FramebufferTarget::Draw) { + Uint32 snormClampOutputMask = 0; + Uint32 unormClampOutputMask = 0; + Uint32 alphaWidenedMask = 0; + Uint32 integerColorMask = 0; + for (Uint i = 0; i < FramebufferObject::MAX_DRAW_BUFFERS && i < 32; ++i) { + const auto frontendBuf = stateDrawBuffers[i]; + if (frontendBuf < FramebufferAttachmentType::Color0 || + frontendBuf > FramebufferAttachmentType::Color31) { + continue; + } + const MG_Pipe::MGPSurface* surface = PushedSurfaceForAttachment(record, frontendBuf); + if (surface == nullptr) continue; // unrepresentable: DecodePushedDrawBuffers' range + if (IsSnormFallbackSurface(*surface)) { + snormClampOutputMask |= (1u << i); + } else if (IsUnormFallbackSurface(*surface)) { + unormClampOutputMask |= (1u << i); } + if (IsAlphaWidenedColorSurface(*surface)) alphaWidenedMask |= (1u << i); + if (IsIntegerColorSurface(*surface)) integerColorMask |= (1u << i); } -#endif + PrgramImpl::g_snormFallbackClampOutputMask = snormClampOutputMask; + PrgramImpl::g_unormFallbackClampOutputMask = unormClampOutputMask; + g_alphaWidenedDrawBufferMask = alphaWidenedMask; + g_integerColorDrawBufferMask = integerColorMask; } - // The walk itself can re-mint an id (SyncAttachmentObject -> - // SyncMipmapsToBackend -> RecreateBackendTexture), invalidating points this - // walk already attached or version-skipped - e.g. one texture attached at two - // points. Re-enter until the generation is quiescent: every pass syncs each - // dirty texture clean, so each repeat finds strictly fewer re-mints and the - // common case (no re-mint) never takes a second pass. The head's draw/read- - // buffer syncs are memoized against their own shadows, so a repeat re-walks - // only the attachments. + // 2. Remap read buffer, READ-target only, for the reason the object form gives. + if (asTarget == FramebufferTarget::Read) { + ApplyReadBufferFromRecord(record, 0); + } + + // 3. The attachments. Two gates, both server-owned. + const Bool idGenerationMoved = m_syncedBackendIdGeneration != g_attachmentBackendIdGeneration; + if (idGenerationMoved) { + m_syncedBackendIdGeneration = g_attachmentBackendIdGeneration; + } + if (idGenerationMoved || attachmentPointsMoved || + m_syncedRecordHashes[SizeT(asTarget)] != record.ContentHash) { + const auto applyPoint = [&](FramebufferAttachmentType point, + const MG_Pipe::MGPSurface& surface) { + const Bool isColorPoint = point >= FramebufferAttachmentType::Color0 && + point <= FramebufferAttachmentType::Color31; + const GLenum glBackendAttachment = + isColorPoint ? GetBackendAttachmentType(point) + : MG_Util::ConvertFramebufferAttachmentTypeToGLEnum(point); + if (glBackendAttachment == GL_NONE || glBackendAttachment == GL_UNKNOWN_MGL) return; + const Bool empty = surface.Kind == MG_Pipe::kMGPipeSurfaceKindNone || + MG_Pipe::MGPipeHandleIsNull(surface.Res); + // The detach that keeps m_backendColorSlots a permutation of the PHYSICAL + // layout: a colour point whose record says empty must not keep the previous + // owner's image, or glReadBuffer returns it. Bounded by the driver's cap, + // because GL_COLOR_ATTACHMENTn above it is INVALID_ENUM. + if (isColorPoint && empty) { + const Int index = + static_cast(point) - static_cast(FramebufferAttachmentType::Color0); + if (index < g_GLESCapabilities.MaxColorAttachments) { + g_GLESFuncs.glFramebufferRenderbuffer(glFBOTarget, glBackendAttachment, + GL_RENDERBUFFER, 0); + } + return; + } + // No frontend attachment on this arm, by construction: the record IS the + // point set here (§5.4). Null is what selects nothing - the transport test + // inside is what selects the by-handle storage syncs. + SyncAttachmentSurface(glFBOTarget, surface, glBackendAttachment, + /*monolithAttachment=*/nullptr); + }; + for (Uint i = 0; i < MG_Pipe::kMGPipeMaxColorAttachments; ++i) { + applyPoint(static_cast( + static_cast(FramebufferAttachmentType::Color0) + static_cast(i)), + record.Color[i]); + } + applyPoint(FramebufferAttachmentType::Depth, record.Depth); + applyPoint(FramebufferAttachmentType::Stencil, record.Stencil); + m_syncedRecordHashes[SizeT(asTarget)] = record.ContentHash; + NoteFramebufferTextureAttachments(fbo, record); + } + + // The walk itself can re-mint a driver id (the storage sync behind a texture + // surface), invalidating points this walk already attached. Re-enter until the + // generation is quiescent, exactly as the object form does - every pass syncs each + // dirty texture clean, so each repeat finds strictly fewer re-mints. if (m_syncedBackendIdGeneration != g_attachmentBackendIdGeneration) { - SyncToBackend(stateFBOObject, asTarget); + SyncToBackendByHandle(fbo, asTarget); } } +#endif // MOBILEGL_PIPE_PUSH GLenum BackendFramebufferObject::GetBackendAttachmentType(FramebufferAttachmentType frontendAtt) const { // Only colour attachments are ever relocated; depth/stencil, the default framebuffer's @@ -5752,7 +11265,7 @@ namespace MobileGL::MG_Backend::DirectGLES { return m_backendColorSlots[index]; } - StateBackendObjectRegistry + TwinRegistry g_backendFramebufferObjects; Array g_fboSyncedSlotVersions = {0}; // Tracks the bound FBO's object version (bumped on any attachment/drawbuffer change) @@ -6063,7 +11576,244 @@ namespace MobileGL::MG_Backend::DirectGLES { // context never answers GL_NO_ERROR, and the build runs on the thread that would // then spin forever. constexpr Int kMaxDrainedProgramErrors = 32; - StateBackendObjectRegistry g_backendProgramObjects; + TwinRegistry g_backendProgramObjects; + +#if MOBILEGL_PIPE_PUSH + // ---- P5e (pg): the source, both ways round --------------------------------------- + + Int ProgramArchiveSource::GetUniformLocation(const String& name) const { + // ProgramObject::GetUniformLocation over the archive. Reproduced rather than called + // because the frontend's form is a non-static member over Artifacts() and this arm + // has no ProgramObject to call it on; the ARRAY RULES are the whole body and they + // are the part a paraphrase would get wrong, so they are transcribed exactly. + const auto it = Link->uniformLocations.find(name); + if (it != Link->uniformLocations.end()) return static_cast(it->second); + if (name.empty()) return -1; + // Reflection stores GL-style names: an array uniform is keyed "arr[0]" at its base + // location, so a bare "arr" resolves to that entry. + if (name.back() != ']') { + const auto suffixed = Link->uniformLocations.find(name + "[0]"); + if (suffixed != Link->uniformLocations.end()) return static_cast(suffixed->second); + return -1; + } + if (name.length() < 4) return -1; + // An ARRAY OF ARRAYS is keyed by its full "[0]"-terminated spelling ("a[2][1][0]"), + // so a query that already ends in a subscript may still be the NAME of an array + // rather than an element of one. That reading is tried FIRST; only then is the + // trailing subscript read as an element index. + { + const auto arrayOfArrays = Link->uniformLocations.find(name + "[0]"); + if (arrayOfArrays != Link->uniformLocations.end()) { + return static_cast(arrayOfArrays->second); + } + } + const SizeT bracket = name.rfind('['); + if (bracket == String::npos || bracket + 1 >= name.length() - 1) return -1; + Uint element = 0; + for (SizeT i = bracket + 1; i < name.length() - 1; ++i) { + if (name[i] < '0' || name[i] > '9') return -1; + element = element * 10 + static_cast(name[i] - '0'); + if (element > 0x0FFFFFFFu) return -1; + } + auto base = Link->uniformLocations.find(name.substr(0, bracket) + "[0]"); + if (base == Link->uniformLocations.end()) { + base = Link->uniformLocations.find(name.substr(0, bracket)); + if (base == Link->uniformLocations.end()) return -1; + } + const Int baseLocation = static_cast(base->second); + if (!IsValidUniformLocation(baseLocation)) return -1; + const Int index = Link->uniformIndexInTProgram[baseLocation]; + if (!UniformAt(index).type.isArray) return -1; + if (static_cast(element) >= + MG_State::GLState::ProgramObject::GetUniformArraySizeByTIndex(*Link, index)) { + return -1; + } + const Int location = baseLocation + static_cast(element); + if (!UniformLocationsAliasSameUniform(baseLocation, location)) return -1; + return location; + } + + const String& ProgramArchiveSource::GetUniformBlockName(Uint index) const { + static const String kEmpty; + if (index >= Link->glBlockIndexToTProgram.size()) return kEmpty; + const Int tBlockIndex = Link->glBlockIndexToTProgram[index]; + if (tBlockIndex < 0 || static_cast(tBlockIndex) >= Link->blockReflection.size()) { + return kEmpty; + } + return Link->blockReflection[tBlockIndex].name; + } + + Uint ProgramArchiveSource::GetUniformBlockBinding(Uint index) const { + // THE OVERLAY WINS WHEN THERE IS ONE, and the archive's link-time value stands when + // there is not. glUniformBlockBinding moves this AFTER the link that produced the + // archive, so a twin answering from the archive alone would bind the program's + // blocks to the points it was LINKED with rather than the ones it is BOUND with. + if (OverlayGoverns && BlockBindingOverlay != nullptr) { + if (index < BlockBindingOverlay->size()) { + return static_cast((*BlockBindingOverlay)[index]); + } + // A block past the tail the record declared. The tail is dense over + // GetActiveUniformBlocksCount(), so this is a record and an archive that + // disagree about how many blocks the program has - which can only happen if a + // create and a bindings record crossed. 0 is GL's own default and is the safe + // answer; the mismatch is the emitter's to fix, not this read's to hide. + return 0; + } + if (index >= Link->uniformBlockBinding.size()) return 0; + return static_cast(Link->uniformBlockBinding[index]); + } + + Int ProgramArchiveSource::GetUniformSamplerOrImageUnitIndex(Uint location) const { + if (OverlayGoverns) { + // THE TAIL IS THE WHOLE SET, so a location it does not name has no unit - NOT + // whatever the archive's link-time snapshot said. The client emits every + // assigned unit it can see, including the ones the link seeded from + // layout(binding=N), so "absent" really does mean -1. + // + // Sparse and ASCENDING BY LOCATION on the wire (CONTRACT-P5E §1), which is what + // lets this be a binary search: the build walks every location from 0 to + // maxUniformLocation, and a linear scan per location would be quadratic on a + // program with a thousand locations and a handful of samplers. + if (SamplerUnitOverlay == nullptr) return -1; + const auto it = std::lower_bound( + SamplerUnitOverlay->begin(), SamplerUnitOverlay->end(), location, + [](const MG_Pipe::MGPProgramSamplerUnit& entry, Uint value) { + return entry.Location < value; + }); + if (it == SamplerUnitOverlay->end() || it->Location != location) return -1; + return static_cast(it->Unit); + } + if (location >= Link->uniformSamplerOrImageUnitIndex.size()) return -1; + return Link->uniformSamplerOrImageUnitIndex[location]; + } + + ProgramArchiveSource ProgramArchiveSource::FromFrontend( + const MG_State::GLState::ProgramObject& program) { + // THE MONOLITH-GLUE HALF (ruling 1 / ID-81). The "archive" is the frontend's own + // live tables, which is what makes this arm byte-for-byte what it was: there is no + // overlay because the three mutable fields ARE those tables' members, and the + // program object is pinned by the draw that is reading it. + ProgramArchiveSource source; + source.Link = &program.GetLinkReflection(); + source.Spirv = &program.GetSpirvReflection(); + source.Identity = program.GetExternalIndex(); + source.IdentityIsHandleSlot = false; + source.Linked = program.GetLinkStatus(); + source.SpirvUsable = program.GetSpirvStatus(); + source.SpirvValidationEnabled = program.GetSpirvValidationEnabled(); + source.PointSizeWasDemoted = program.PointSizeDemoted(); + source.GlobalUboSize = program.GetUBOSize(); + source.LinkedStages = program.GetLinkedShaderStages(); + source.OverlayGoverns = false; + source.StorageOverrides = program.GetShaderStorageBlockBindingOverrides(); + // The function this source's ComputeShaderStorageBlockBindingSignature overload + // replaces, so the monolith answer is unchanged and there is still exactly one + // place the number comes from on this arm. + source.StorageOverrideSignature = ComputeShaderStorageBlockBindingSignatureOf(program); + return source; + } + + ProgramArchiveSource ProgramArchiveSource::FromRecord( + MG_Pipe::MGPipeHandle cso, const MG_Pipe::MGPipeShaderCsoRecord& record) { + // THE HANDLE ARM. Every field below is server-owned: the archive the create adopted, + // the descriptor the create stored, and the three tails set_program_bindings + // carried. Nothing here touches a ProgramObject, which is the property the strict + // lane measures. + ProgramArchiveSource source; + source.Link = &record.Archive->Link; + source.Spirv = &record.Archive->Spirv; + source.Identity = cso.Slot; + source.IdentityIsHandleSlot = true; + // LinkStatus IS A FIELD NOW, and that is the whole point of c0e adding it: before + // P5e "linked" was only implied by the create existing, which is a different + // statement - create_shader_state is re-issued at every link that moves the link + // version, and a FAILED relink of a bound program moves it too (ID-88). + source.Linked = record.Desc.LinkStatus != 0; + source.SpirvUsable = record.Desc.SpirvStatus != 0; + source.SpirvValidationEnabled = record.Desc.EnableSpirvValidation != 0; + source.PointSizeWasDemoted = record.Desc.PointSizeDemoted != 0; + source.GlobalUboSize = record.Desc.GlobalUboSize; + source.LinkedStages.reserve(record.Archive->LinkedStages.size()); + for (const Uint32 stage : record.Archive->LinkedStages) { + source.LinkedStages.push_back(static_cast(stage)); + } + // "HAS A BINDINGS RECORD EVER BEEN APPLIED TO THIS RECORD" - not "is the tail + // non-empty". A program with no uniform blocks, no sampler and no override emits a + // record with three empty tails, and that record is the statement that the archive's + // link-time values are no longer the answer. + source.OverlayGoverns = record.BindingsSerial != 0; + source.BlockBindingOverlay = &record.BlockBindings; + source.SamplerUnitOverlay = &record.SamplerUnits; + if (source.OverlayGoverns) { + source.StorageOverrides.reserve(record.StorageOverrides.size()); + for (const auto& entry : record.StorageOverrides) { + source.StorageOverrides[entry.Name] = entry.Binding; + } + } else { + source.StorageOverrides = record.Archive->Link.shaderStorageBlockBinding; + } + // THE RECORD'S NUMBER EITHER WAY, and deliberately not a recomputation when the + // overlay is absent. This is the value the twin STAMPS and the draw path's clean + // clause COMPARES (CONTRACT-P5E §5.5 names record.Signature as the input), so the + // two have to be the same field or every draw of a program with an override + // rebuilds. A record with no bindings applied carries 0 while the archive may hold + // a link-time override map - the next set_program_bindings then produces a non-zero + // signature and forces exactly one rebuild, which is correct and, since the emitter + // sends the bindings in the same breath as the create, not a state a draw reaches. + source.StorageOverrideSignature = record.Signature; + return source; + } + + Uint ProgramBlockBindingFromRecord(const MG_Pipe::MGPipeShaderCsoRecord& record, Int blockIndex) { + if (blockIndex < 0) return 0; + const SizeT index = static_cast(blockIndex); + if (record.BindingsSerial != 0) { + // The tail is the whole set; a block past it is a record and an archive that + // disagree about the block count, and GL's own default of 0 is the safe answer. + return index < record.BlockBindings.size() + ? static_cast(record.BlockBindings[index]) + : 0; + } + if (!record.Archive || index >= record.Archive->Link.uniformBlockBinding.size()) return 0; + return static_cast(record.Archive->Link.uniformBlockBinding[index]); + } + + Int ProgramSamplerUnitFromRecord(const MG_Pipe::MGPipeShaderCsoRecord& record, Uint location) { + if (record.BindingsSerial != 0) { + const auto it = std::lower_bound( + record.SamplerUnits.begin(), record.SamplerUnits.end(), location, + [](const MG_Pipe::MGPProgramSamplerUnit& entry, Uint value) { + return entry.Location < value; + }); + if (it == record.SamplerUnits.end() || it->Location != location) return -1; + return static_cast(it->Unit); + } + if (!record.Archive || + location >= record.Archive->Link.uniformSamplerOrImageUnitIndex.size()) { + return -1; + } + return record.Archive->Link.uniformSamplerOrImageUnitIndex[location]; + } + + // P5e (id), CONTRACT-P5E §4.1. See Managers.h for the contract - in particular why the + // composite band had to land in the same package as this function. + BackendProgramObjectImpl* ResolveProgramTwin(MG_Pipe::MGPipeHandle cso) { + if (MG_Pipe::MGPipeHandleIsNull(cso)) return nullptr; + // THE RECORD FIRST, and through the BAND-AWARE reader: a composite pipeline's CSO + // lives in the applier's CompositeShaderCsos table, and asking the ordinary one for + // it would answer null for a program that is perfectly live. + if (PipeShaderCsoRecordForHandle(cso) == nullptr) { + MGLOG_E_ONCE("MGPipe: shader CSO {%u, %u} has no applier record on the handle arm, so " + "no driver program can be built for it and the draw keeps what is bound", + cso.Slot, cso.Gen); + return nullptr; + } + auto* slot = AdoptTwinByHandle(g_backendProgramObjects, cso, "shader CSO"); + if (slot == nullptr) return nullptr; + if (!*slot) *slot = MakeShared(); + return slot->get(); + } +#endif BackendProgramObjectImpl::BackendProgramObjectImpl() { #ifdef TRACY_ENABLE @@ -6116,8 +11866,8 @@ namespace MobileGL::MG_Backend::DirectGLES { } void ReseedShaderStorageBlockBindings(Uint backendProgramId, - const MG_State::GLState::ProgramObject& stateProgramObject) { - const auto& overrides = stateProgramObject.GetShaderStorageBlockBindingOverrides(); + const ProgramBuildSource& src) { + const auto& overrides = src.GetShaderStorageBlockBindingOverrides(); if (overrides.empty()) return; // the overwhelming majority of programs for (const auto& [blockName, binding] : overrides) { if (binding < 0) continue; @@ -6125,9 +11875,51 @@ namespace MobileGL::MG_Backend::DirectGLES { } } - Uint64 ComputeShaderStorageBlockBindingSignature( - const MG_State::GLState::ProgramObject& stateProgramObject) { - const auto& overrides = stateProgramObject.GetShaderStorageBlockBindingOverrides(); +#if MOBILEGL_PIPE_PUSH + // P5e (pg). ON A PUSH BUILD THE SIGNATURE IS THE SOURCE'S, TAKEN VERBATIM, and it is not + // recomputed here for a reason worth stating: on the handle arm the number the DRAW PATH + // compares against is MGPipeShaderCsoRecord::Signature - the CLIENT's commutative hash, + // computed in MG_Impl/Pipe/ProgramEmit.h. If the server recomputed its own from the + // record's override tail, the two formulas would have to stay bit-identical for ever or + // every draw of every program with an override would rebuild. Taking the client's number + // verbatim removes that coupling: there is one authority, and the twin's stamp and the + // clean clause read the same field. The monolith arm's source carries the answer of the + // function this replaces, computed from the frontend map, so its behaviour is unchanged. + Uint64 ComputeShaderStorageBlockBindingSignature(const ProgramBuildSource& src) { + return src.StorageOverrideSignature; + } + + // The COMPUTATION, still here and still the only one on this side: FromFrontend seeds a + // monolith source with it, and the monolith arm of SyncCurrentProgram's clean condition + // asks it per draw exactly as it always did. In a pull build this function does not + // exist and the overload above IS this body (see the #else), so the pull build gains no + // symbol - G1's rule for every new name in this phase. + Uint64 ComputeShaderStorageBlockBindingSignatureOf( + const MG_State::GLState::ProgramObject& program) { + const auto& overrides = program.GetShaderStorageBlockBindingOverrides(); + if (overrides.empty()) return 0; // the overwhelming majority of programs + // Order-independent on purpose: the source is an UnorderedMap, so any signature that + // depended on iteration order would differ between two identical override sets and + // rebuild the program for nothing. Built from the VALUES, not from a change counter, + // so re-setting a block to the binding it already carries forces no rebuild - which + // is what the pipeline composite's per-draw uniform mirror depends on. + // + // MG_Impl/Pipe/ProgramEmit.h's MGPipeStorageOverrideSignatureEntry is this entry + // mix, verbatim, on the client side; the two are twins with a named pointer at each + // other because MG_Backend includes nothing from MG_Impl. + Uint64 signature = 0; + for (const auto& [blockName, binding] : overrides) { + if (binding < 0) continue; // never rebound; the declared qualifier still stands + Uint64 entry = std::hash{}(blockName); + entry ^= (static_cast(static_cast(binding)) + 0x9e3779b97f4a7c15ull + + (entry << 6) + (entry >> 2)); + signature += entry; // commutative combine + } + return signature; + } +#else + Uint64 ComputeShaderStorageBlockBindingSignature(const ProgramBuildSource& src) { + const auto& overrides = src.GetShaderStorageBlockBindingOverrides(); if (overrides.empty()) return 0; // the overwhelming majority of programs // Order-independent on purpose: the source is an UnorderedMap, so any signature that // depended on iteration order would differ between two identical override sets and @@ -6149,13 +11941,26 @@ namespace MobileGL::MG_Backend::DirectGLES { } return signature; } +#endif namespace { // The GL internal format bound to an image unit right now. GL_NONE for a unit // outside the frontend's array, which cannot be addressed at all. Uint BoundImageUnitFormat(Int unit) { if (unit < 0 || unit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) return 0; - return static_cast(MG_State::pGLContext->GetImageTextureBinding(unit).Format); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (fb, §5.4): the format-less image bake asks what format the APPLICATION + // named at this unit, and under a transport the record is the only statement of + // it - MGPImageView::InternalFormat is that same GLenum, copied by the client + // out of the unit's own binding. A unit the applier has never been told about + // answers 0, which is what an unbound unit answers on the frontend arm too. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const auto& st = MG_Pipe::MGPipeApplier(); + if (static_cast(unit) >= st.BoundShaderImages.size()) return 0; + return static_cast(st.BoundShaderImages[static_cast(unit)].InternalFormat); + } +#endif + return static_cast(MGB_CTX->GetImageTextureBinding(unit).Format); } // Combines one (unit, format) pair into a running digest. Commutative, so the order @@ -6325,8 +12130,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // reflection: which image uniforms declared NO format (the only ones a bake may touch - // a declared format is authoritative and stays), what the units they address currently // hold, and whether any format in play - declared or baked - is outside the ES core set. - ImageFormatBakeInputs CollectImageFormatBakeInputs( - const MG_State::GLState::ProgramObject& stateProgramObject) { + ImageFormatBakeInputs CollectImageFormatBakeInputs(const ProgramBuildSource& src) { ImageFormatBakeInputs inputs; // A format GLSL ES cannot spell on a driver with no GL_NV_image_formats to spell it // with. There is no legal ESSL for such a shader at all, so the stage will not @@ -6344,12 +12148,12 @@ namespace MobileGL::MG_Backend::DirectGLES { ++unspellableCount; }; - const Uint maxUniformLoc = stateProgramObject.GetMaxUniformLocation(); + const Uint maxUniformLoc = src.GetMaxUniformLocation(); for (Uint loc = 0; loc <= maxUniformLoc; ++loc) { - const auto& name = stateProgramObject.GetUniformName(loc); + const auto& name = src.GetUniformName(loc); if (name.empty()) continue; - if (!IsImageUniformType(stateProgramObject.GetUniformType(loc))) continue; - const auto& type = stateProgramObject.GetUniformTypeFacts(loc); + if (!IsImageUniformType(src.GetUniformType(loc))) continue; + const auto& type = src.GetUniformTypeFacts(loc); if (type.hasFormat) { // Declared, and therefore never overridden by the BAKE - but a non-core // spelling still has to become legal ESSL somehow. @@ -6375,7 +12179,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } continue; } - const Int unit = stateProgramObject.GetUniformSamplerOrImageUnitIndex(loc); + const Int unit = src.GetUniformSamplerOrImageUnitIndex(loc); if (unit < 0 || unit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) continue; const Uint boundFormat = BoundImageUnitFormat(unit); @@ -6494,27 +12298,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // so there is nothing to fix at the API end and the emitted text has to carry it // (RemapImageArrayElementUnits). Empty for every program that does not do this, which is // very nearly all of them - one walk of the reflection and no allocation in that case. - Vector CollectNonConsecutiveImageArrayPlans( - const MG_State::GLState::ProgramObject& stateProgramObject) { + Vector CollectNonConsecutiveImageArrayPlans(const ProgramBuildSource& src) { Vector plans; - const Uint maxUniformLoc = stateProgramObject.GetMaxUniformLocation(); + const Uint maxUniformLoc = src.GetMaxUniformLocation(); for (Uint loc = 0; loc <= maxUniformLoc; ++loc) { - const auto& name = stateProgramObject.GetUniformName(loc); + const auto& name = src.GetUniformName(loc); if (name.empty()) continue; - if (!IsImageUniformType(stateProgramObject.GetUniformType(loc))) continue; + if (!IsImageUniformType(src.GetUniformType(loc))) continue; // Reflection repeats the array's "g_image[0]" spelling at EVERY location the array // spans, so only the location that name resolves back to is the array itself. - if (stateProgramObject.GetUniformLocation(name) != static_cast(loc)) continue; + if (src.GetUniformLocation(name) != static_cast(loc)) continue; const String baseName = ImageUniformBaseName(name); if (baseName == name) continue; // a scalar image: one binding says it all ImageArrayUnitPlan plan; plan.name = baseName; for (Uint element = loc; element <= maxUniformLoc && - stateProgramObject.UniformLocationsAliasSameUniform( + src.UniformLocationsAliasSameUniform( static_cast(loc), static_cast(element)); ++element) { - plan.units.push_back(stateProgramObject.GetUniformSamplerOrImageUnitIndex(element)); + plan.units.push_back(src.GetUniformSamplerOrImageUnitIndex(element)); } if (plan.units.size() < 2) continue; @@ -7110,26 +12913,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // the program exactly as dead as it already was - but with a driver log that says why, // where today there is an empty one. void BackendProgramObjectImpl::AttachPassthroughTessControlStage( - const MG_State::GLState::ProgramObject& stateProgramObject, const Int tessEvalShaderIndex, + const ProgramBuildSource& src, const Int tessEvalShaderIndex, const Vector>& shaderSpirvs, const String& vertexStageEssl, const String& tessEvalStageEssl) { // PATCH_VERTICES is dynamic state, and it decides the synthesized stage's output // patch size - so a program built for one value is stale for another. Recorded here // and compared on the draw path (SyncCurrentProgram), the same shape as the // storage-block and image-format signatures next to it. - const Uint patchVertices = MG_State::pGLContext != nullptr - ? MG_State::pGLContext->GetPatchVertices() + const Uint patchVertices = MGB_CTX_LIVE + ? MGB_CTX->GetPatchVertices() : 3u; m_passthroughTessControlPatchVertices = static_cast(patchVertices); // PATCH_DEFAULT_{OUTER,INNER}_LEVEL are the same kind of dynamic state and are baked // into the same stage (ES has no such state and no entry point to forward them to), so // they are recorded and compared alongside the patch size - the two move together, as // BuildPassthroughTessControlEssl's contract says. - m_passthroughTessControlOuterLevel = MG_State::pGLContext != nullptr - ? MG_State::pGLContext->GetPatchDefaultOuterLevel() + m_passthroughTessControlOuterLevel = MGB_CTX_LIVE + ? MGB_CTX->GetPatchDefaultOuterLevel() : FloatVec4(1.0f, 1.0f, 1.0f, 1.0f); - m_passthroughTessControlInnerLevel = MG_State::pGLContext != nullptr - ? MG_State::pGLContext->GetPatchDefaultInnerLevel() + m_passthroughTessControlInnerLevel = MGB_CTX_LIVE + ? MGB_CTX->GetPatchDefaultInnerLevel() : FloatVec2(1.0f, 1.0f); if (tessEvalShaderIndex < 0 || @@ -7137,7 +12940,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E("Program %u has a tessellation evaluation stage with no control stage, but no " "SPIR-V for it; the pass-through control stage GL describes cannot be checked, so " "the program is left to fail its ES link.", - stateProgramObject.GetExternalIndex()); + src.GetExternalIndex()); m_backendProgramUsable = false; return; } @@ -7153,7 +12956,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E("Program %u has a tessellation evaluation stage with no control stage AND reads a " "user-defined input through it; a synthesized pass-through control stage cannot " "forward that, so the program is declined rather than fed an undefined varying.", - stateProgramObject.GetExternalIndex()); + src.GetExternalIndex()); m_backendProgramUsable = false; return; } @@ -7191,7 +12994,7 @@ namespace MobileGL::MG_Backend::DirectGLES { if (backendShaderId == 0) { MGLOG_E("Failed to create the synthesized pass-through tessellation control shader for " "program %u.", - stateProgramObject.GetExternalIndex()); + src.GetExternalIndex()); m_backendProgramUsable = false; return; } @@ -7199,7 +13002,7 @@ namespace MobileGL::MG_Backend::DirectGLES { const char* sourceCStr = source.c_str(); MGLOG_D("Synthesized pass-through tessellation control stage for program %u (patch vertices " "%u):\n%s", - stateProgramObject.GetExternalIndex(), patchVertices, sourceCStr); + src.GetExternalIndex(), patchVertices, sourceCStr); g_GLESFuncs.glShaderSource(backendShaderId, 1, &sourceCStr, nullptr); g_GLESFuncs.glCompileShader(backendShaderId); @@ -7216,7 +13019,7 @@ namespace MobileGL::MG_Backend::DirectGLES { log.back() = '\0'; MGLOG_E("The synthesized pass-through tessellation control stage failed to compile for " "program %u. Driver log: %s\nSource:\n%s", - stateProgramObject.GetExternalIndex(), log.data(), sourceCStr); + src.GetExternalIndex(), log.data(), sourceCStr); m_backendProgramUsable = false; g_GLESFuncs.glDeleteShader(backendShaderId); return; @@ -7228,6 +13031,25 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glDeleteShader(backendShaderId); } + // ---- THE PROGRAM BUILD, AND THE ONE SOURCE IT READS (P5e pg) ---------------------- + // + // ONE BODY, TWO HEADS, AND THE PULL BUILD'S HEAD IS THE ORIGINAL ONE. In the pull build + // `ProgramBuildSource` IS `MG_State::GLState::ProgramObject`, this function keeps its + // name, its signature and its null guard, and `src` is a reference to the object it was + // already dereferencing on every line - so the pull build compiles the same reads + // through the same accessors and G1's byte identity survives a rename and nothing else. + // + // In a PUSH build the body is a private worker that the two public overloads feed: + // SyncToBackend(program) - the monolith-glue half, ruling 1 - wraps the frontend's own + // archive, and SyncToBackendByHandle(cso) wraps the RECORD's. That is the whole of this + // family's rule F: after this, nothing in a program build dereferences a frontend + // object, and the forty accessor calls below read memory the server owns. +#if MOBILEGL_PIPE_PUSH + void BackendProgramObjectImpl::SyncToBackendFromSource(const ProgramBuildSource& src) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif +#else void BackendProgramObjectImpl::SyncToBackend( const SharedPtr& stateProgramObject) { #ifdef TRACY_ENABLE @@ -7237,24 +13059,26 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E_ONCE("State program object is null, skipping backend sync."); return; } + const ProgramBuildSource& src = *stateProgramObject; +#endif // Recorded before either early return below, so Use() can always name the GL // program a no-op draw belongs to - including the "linked but not drawable" exit. - m_frontendProgramId = stateProgramObject->GetExternalIndex(); + m_frontendProgramId = src.GetExternalIndex(); // GetSpirvStatus() as well as GetLinkStatus(): a program whose phase-B job was // cancelled (teardown) or whose optimizer run failed is fully linked and fully // queryable, but has no SPIR-V to build a driver program out of. GL cannot retract // a LINK_STATUS it already reported true, so "linked but not drawable" is the // answer, and this is where the ES backend expresses it. - if (!stateProgramObject->GetLinkStatus() || !stateProgramObject->GetSpirvStatus()) { + if (!src.GetLinkStatus() || !src.GetSpirvStatus()) { MGLOG_E_ONCE("Program object is not linked or has no generated SPIR-V, skipping backend sync. State " "program ID: %u", - stateProgramObject->GetExternalIndex()); + src.GetExternalIndex()); return; } MGLOG_D("Syncing program to backend. State program ID: %u, Backend ID: %u", - stateProgramObject->GetExternalIndex(), m_backendProgramId); + src.GetExternalIndex(), m_backendProgramId); // Every link-derived cache below (incl. m_samplerUniformBindings and its // lastAssignedUnit/lastAssignedLodBias program-state mirrors) is rebuilt; // the sampler-pass memo keyed on them must not survive. @@ -7266,8 +13090,8 @@ namespace MobileGL::MG_Backend::DirectGLES { // The generated ESSL bakes these in (see the SetShaderStorageBlockBinding call in the // transpile loop below), so the set they were generated against is part of what makes // this build current - the draw path compares the signature and rebuilds on a change. - const auto& storageBlockBindingOverrides = stateProgramObject->GetShaderStorageBlockBindingOverrides(); - m_shaderStorageBlockBindingSignature = ComputeShaderStorageBlockBindingSignature(*stateProgramObject); + const auto& storageBlockBindingOverrides = src.GetShaderStorageBlockBindingOverrides(); + m_shaderStorageBlockBindingSignature = ComputeShaderStorageBlockBindingSignature(src); // Rebuilt by the transpile loop below, one entry per atomic-counter block it finds. // The top is snapshotted here so every stage of this program - and the draw path // reading it afterwards - resolves the same slot for the same GL binding. @@ -7284,19 +13108,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // compiles to depends on live glBindImageTexture state, so the pairs it was built // against are recorded here and compared per draw (ImageUnitFormatsStillMatch). // Taken BEFORE the transpile loop so both the bake and the key see one snapshot. - const ImageFormatBakeInputs imageFormatBake = CollectImageFormatBakeInputs(*stateProgramObject); + const ImageFormatBakeInputs imageFormatBake = CollectImageFormatBakeInputs(src); m_formatlessImageUnits = imageFormatBake.units; m_imageUnitFormatSignature = imageFormatBake.signature; for (const auto& conflicted : imageFormatBake.conflictedNames) { MGLOG_D("Image uniform '%s' of program %u declares no format and its elements address units with " "different bound formats; left format-less.", - conflicted.c_str(), stateProgramObject->GetExternalIndex()); + conflicted.c_str(), src.GetExternalIndex()); } // ...and once more for image ARRAYS whose per-element units are not consecutive, which // ESSL has no way to express in one declaration. Program-wide, like the bake, and read // from the same snapshot of the reflection; the per-stage rewrite happens below. const Vector nonConsecutiveImageArrays = - CollectNonConsecutiveImageArrayPlans(*stateProgramObject); + CollectNonConsecutiveImageArrayPlans(src); // Detach all existing shaders GLint attachedCount = 0; @@ -7333,19 +13157,19 @@ namespace MobileGL::MG_Backend::DirectGLES { // straight off the end of shaderSpirvs (a std::vector copy from garbage, which is // how this crashed). GetLinkedShaderStages() is the list the modules were generated // from, one entry per module, in module order. - const Vector linkedStages = stateProgramObject->GetLinkedShaderStages(); - auto& shaderSpirvs = stateProgramObject->GetGeneratedSpirv(); + const Vector linkedStages = src.GetLinkedShaderStages(); + auto& shaderSpirvs = src.GetGeneratedSpirv(); // Both come from the same Link(), so they agree by construction. If they ever did // not there would be no index this function could safely use for EITHER array, so // this refuses the build instead of picking one and hoping. if (linkedStages.size() != shaderSpirvs.size()) { MGLOG_E_ONCE("Program %u: %zu linked stage(s) but %zu generated SPIR-V module(s); refusing to " "build a backend program from mismatched link artifacts.", - stateProgramObject->GetExternalIndex(), linkedStages.size(), shaderSpirvs.size()); + src.GetExternalIndex(), linkedStages.size(), shaderSpirvs.size()); m_backendProgramUsable = false; return; } - if (stateProgramObject->PointSizeDemoted()) { + if (src.PointSizeDemoted()) { // THE ARMING SIGNAL, INFO on purpose and latched: the integration lane that // pins MOBILEGL_POINT_SIZE_DEMOTION=1 asserts on exactly this line, because // every rendering assertion stays green on a healthy driver whether the @@ -7355,7 +13179,7 @@ namespace MobileGL::MG_Backend::DirectGLES { "in those stages."); } MGLOG_D("Attaching %zu shaders to program %u", linkedStages.size(), m_backendProgramId); - for (const auto& ref : stateProgramObject->GetLinkedShaderSnapshot()) { + for (const auto& ref : src.GetLinkedShaderSnapshot()) { if (!ref.shader) continue; const auto& stage = MG_Util::ConvertGLEnumToString(MG_Util::ConvertShaderStageToGLEnum(ref.shader->GetShaderStage())); @@ -7364,7 +13188,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("Original src @ %s: \n", stage.c_str()); MGLOG_D("%s:", src.empty() ? "" : src.c_str()); } - const Bool enableSpirvValidation = stateProgramObject->GetSpirvValidationEnabled(); + const Bool enableSpirvValidation = src.GetSpirvValidationEnabled(); // Blocks a transform-feedback capture request names a member of ("StageData" of // "StageData.attrib[0]"). The Adreno ES driver accepts such a request, links, and @@ -7373,7 +13197,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // consumer keep matching. gl_PerVertex members ("gl_Position") carry no block // prefix and so never enter this set. std::set xfbCaptureBlockNames; - for (const auto& xfbVarying : stateProgramObject->GetTransformFeedbackVaryings()) { + for (const auto& xfbVarying : src.GetTransformFeedbackVaryings()) { const SizeT dot = xfbVarying.name.find('.'); if (dot != String::npos && dot > 0) { xfbCaptureBlockNames.insert(xfbVarying.name.substr(0, dot)); @@ -7700,7 +13524,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // KHR-GL43.vertex_attrib_binding family behind "the draw captured zeros". MGLOG_E("Shader transpilation to ESSL failed. State program ID: %u, stage: %s, " "SPIRV-Cross error: %s", - stateProgramObject->GetExternalIndex(), + src.GetExternalIndex(), MG_Util::ConvertGLEnumToString(glShaderType).c_str(), transpileError.c_str()); m_backendProgramUsable = false; @@ -7792,13 +13616,13 @@ namespace MobileGL::MG_Backend::DirectGLES { "available on this device.", tessellationStage ? "tessellation" : "geometry", tessellationStage ? "tessellation" : "geometry", - stateProgramObject->GetExternalIndex()); + src.GetExternalIndex()); } source = RequestPointSizeExtension(std::move(source), pointSizeExtension); } } - source = RebindImageUniformsToFrontendUnits(std::move(source), stateProgramObject); + source = RebindImageUniformsToFrontendUnits(std::move(source), src); // The completion half of the format bake, for the formats SPIRV-Cross throws on // rather than prints (r8ui and the rest of its desktop-only set). Empty for every // program whose format-less images bound a format the module could carry, which @@ -7828,7 +13652,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E("Image array %s. Its elements address image units GLSL ES cannot be made to reach " "from one declaration, so this stage will read and write the WRONG units. State " "program ID: %u, stage: %s.", - declined.c_str(), stateProgramObject->GetExternalIndex(), + declined.c_str(), src.GetExternalIndex(), MG_Util::ConvertGLEnumToString(glShaderType).c_str()); } } @@ -7873,7 +13697,7 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E("Program %u routes gl_ViewportIndex but its fragment stage has no " "entry point to gate, so the routing cannot be emulated: every " "index will rasterize against viewport 0. State program ID: %u.", - m_backendProgramId, stateProgramObject->GetExternalIndex()); + m_backendProgramId, src.GetExternalIndex()); } } else if (PromoteViewportIndexGlobalToVarying(source)) { programRoutesViewportIndex = true; @@ -7954,7 +13778,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } MGLOG_E("Shader compilation failed. State program ID: %u, stage: %s, backend shader ID: " "%u, driver log: %s\nSource:\n%s", - stateProgramObject->GetExternalIndex(), + src.GetExternalIndex(), MG_Util::ConvertGLEnumToString(glShaderType).c_str(), backendShaderId, log.data(), sourceForLog); m_backendProgramUsable = false; @@ -7995,7 +13819,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } if (needsPassthroughTessControl) { - AttachPassthroughTessControlStage(*stateProgramObject, tessEvalShaderIndex, shaderSpirvs, + AttachPassthroughTessControlStage(src, tessEvalShaderIndex, shaderSpirvs, vertexStageEssl, tessEvalStageEssl); } @@ -8015,9 +13839,9 @@ namespace MobileGL::MG_Backend::DirectGLES { // the transpiled ESSL (`out vec4 result_0;` stays `result_0`), so the // frontend's requested names carry over unchanged. SizeT declaredXfbVaryingCount = 0; - if (stateProgramObject->GetTransformFeedbackVaryingCount() > 0 && + if (src.GetTransformFeedbackVaryingCount() > 0 && g_GLESFuncs.glTransformFeedbackVaryings != nullptr) { - const auto& xfbVaryings = stateProgramObject->GetTransformFeedbackVaryings(); + const auto& xfbVaryings = src.GetTransformFeedbackVaryings(); Vector xfbNames; xfbNames.reserve(xfbVaryings.size()); // A block this build flattened no longer HAS the member the application asked @@ -8032,7 +13856,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // capture stage is the vertex shader keeps the built-in and its spelling, // whatever happened to a control stage behind it. Bool captureStageDemoted = false; - if (stateProgramObject->PointSizeDemoted()) { + if (src.PointSizeDemoted()) { for (const ShaderStage linkedStage : linkedStages) { if (linkedStage == ShaderStage::TessEval || linkedStage == ShaderStage::Geometry) { captureStageDemoted = true; @@ -8065,7 +13889,7 @@ namespace MobileGL::MG_Backend::DirectGLES { } g_GLESFuncs.glTransformFeedbackVaryings(m_backendProgramId, static_cast(xfbNames.size()), xfbNames.data(), - stateProgramObject->GetTransformFeedbackBufferMode()); + src.GetTransformFeedbackBufferMode()); // Unchecked before. A rejected capture set leaves the program linking happily // with NO capture set at all, and then every draw of every span records // nothing while the application reads its buffer's pre-draw bytes and @@ -8080,8 +13904,8 @@ namespace MobileGL::MG_Backend::DirectGLES { "%s (mode %s): [%s]. Every capture made with GL program %u will record nothing.", m_backendProgramId, MG_Util::ConvertGLEnumToString(xfbError).c_str(), MG_Util::ConvertGLEnumToString( - stateProgramObject->GetTransformFeedbackBufferMode()).c_str(), - declared.c_str(), stateProgramObject->GetExternalIndex()); + src.GetTransformFeedbackBufferMode()).c_str(), + declared.c_str(), src.GetExternalIndex()); } declaredXfbVaryingCount = xfbNames.size(); } @@ -8104,7 +13928,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // links nothing no-ops every draw that uses it, and that has to be readable // in an INFO-level artifact. MGLOG_E("Program linking failed. State program ID: %u, backend program ID: %u, driver log: %s", - stateProgramObject->GetExternalIndex(), m_backendProgramId, log.data()); + src.GetExternalIndex(), m_backendProgramId, log.data()); // The one link failure MobileGL can name a cause for that the driver's log never // will: ESSL has no legal single declaration for a read+write image outside // r32f/r32i/r32ui, so those are split into a coherent pair and the stage ends up @@ -8144,13 +13968,13 @@ namespace MobileGL::MG_Backend::DirectGLES { &linkedXfbBufferMode); for (Int i = 0; i < kMaxDrainedProgramErrors && g_GLESFuncs.glGetError() != GL_NO_ERROR; ++i) { } - const GLenum requestedMode = stateProgramObject->GetTransformFeedbackBufferMode(); + const GLenum requestedMode = src.GetTransformFeedbackBufferMode(); if (static_cast(std::max(linkedXfbVaryings, 0)) != declaredXfbVaryingCount || static_cast(linkedXfbBufferMode) != requestedMode) { MGLOG_E("Backend program %u (GL program %u) linked with a capture set the driver does not " "agree with: asked for %zu varying(s) in mode %s, the driver reports %d varying(s) " "in mode %s. Captures made with it will be empty or wrongly laid out.", - m_backendProgramId, stateProgramObject->GetExternalIndex(), declaredXfbVaryingCount, + m_backendProgramId, src.GetExternalIndex(), declaredXfbVaryingCount, MG_Util::ConvertGLEnumToString(requestedMode).c_str(), linkedXfbVaryings, MG_Util::ConvertGLEnumToString( static_cast(linkedXfbBufferMode)).c_str()); @@ -8215,30 +14039,128 @@ namespace MobileGL::MG_Backend::DirectGLES { } // Create global UBO - if (stateProgramObject->GetUBOSize() > 0) { + if (src.GetUBOSize() > 0) { g_GLESFuncs.glGenBuffers(1, &m_backendGlobalUBOId); g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, m_backendGlobalUBOId); - g_GLESFuncs.glBufferData(GL_UNIFORM_BUFFER, stateProgramObject->GetUBOSize(), nullptr, GL_STREAM_DRAW); + g_GLESFuncs.glBufferData(GL_UNIFORM_BUFFER, src.GetUBOSize(), nullptr, GL_STREAM_DRAW); g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, 0); } else { m_backendGlobalUBOId = 0; } - CacheResourceLocations(stateProgramObject); + CacheResourceLocations(src); // NOT the mechanism that makes a rebinding work - the transpiled qualifier above is. // glShaderStorageBlockBinding is a GL 4.3 entry point that no real ES driver exposes, // so this replay is a no-op almost everywhere; it stays because it is still correct // (and cheaper than a rebuild) on a driver that does expose it, e.g. a desktop GL // driver used as the ES backend. AFTER the link either way, because it needs the // driver's linked interface. - ReseedShaderStorageBlockBindings(m_backendProgramId, *stateProgramObject); - m_syncedLinkVersion = stateProgramObject->GetLinkVersion(); - m_syncedImageUnitVersion = stateProgramObject->GetImageUnitVersion(); + ReseedShaderStorageBlockBindings(m_backendProgramId, src); +#if !MOBILEGL_PIPE_PUSH + m_syncedLinkVersion = src.GetLinkVersion(); + m_syncedImageUnitVersion = src.GetImageUnitVersion(); +#endif m_isInitialized = true; MGLOG_D("Program sync completed. backend ID %u", m_backendProgramId); } +#if MOBILEGL_PIPE_PUSH + // ---- the two public heads (P5e pg) ----------------------------------------------- + // + // THE CLEAN KEYS ARE STAMPED HERE AND NOT IN THE WORKER, because which key is + // authoritative is exactly what distinguishes the two arms - and a worker that stamped + // both would be claiming to know something it was not told. The monolith half keeps the + // frontend's two versions (and the record serial P4a added beside them); the handle half + // keeps the record's two serials and leaves the frontend versions at their + // never-matched sentinel, which is honest: on that arm nothing may ask a ProgramObject + // what its link version is. + void BackendProgramObjectImpl::SyncToBackend( + const SharedPtr& stateProgramObject) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + if (!stateProgramObject) { + MGLOG_E_ONCE("State program object is null, skipping backend sync."); + return; + } + SyncToBackendFromSource(ProgramArchiveSource::FromFrontend(*stateProgramObject)); + m_syncedLinkVersion = stateProgramObject->GetLinkVersion(); + m_syncedImageUnitVersion = stateProgramObject->GetImageUnitVersion(); + // P4a (D-B3): the ShaderCso record's Serial, stamped in the same breath as the two + // frontend versions it replaces. GetSyncedShaderCsoSerial() beside + // GetSyncedLinkVersion()/GetSyncedImageUnitVersion() is what the draw path's + // nine-clause rebuild condition reads on the handle arm; the clause COUNT does not + // shrink, its inputs move (D-H5). + if (ProgramSubsystemEnabled()) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): MONOLITH GLUE ONLY. This is the frontend-keyed + // lookup the phase exists to retire, and it survives here for the reason + // ruling 1 (ID-81) gives: under Transport=monolith the push build keeps its + // frontend arms token for token, and this arm is only ever reached from them. + // The handle half below names no frontend identity at all. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + // The reader hides the composite band, so a program-pipeline composite - which + // the server must never learn is one - resolves through the same call. + const MG_Pipe::MGPipeHandle cso = g_backendProgramObjects.HandleOf(stateProgramObject.get()); + const auto* record = PipeShaderCsoRecordForHandle(cso); + if (record != nullptr) { + m_syncedShaderCsoSerial = record->Serial; + m_syncedBindingsSerial = record->BindingsSerial; + } else { + // NOT a fall-back and not a silent zero: a stamped 0 would make every later + // serial compare fire for ever, which is the safe direction but hides the + // missing record. Name it and leave the memo where it was. + MGLOG_E_ONCE("MGPipe: program %u has no shader-CSO applier record on the handle " + "arm, so its synced serial cannot be stamped (handle {%u, %u})", + stateProgramObject->GetExternalIndex(), cso.Slot, cso.Gen); + } + } + } + + // P5e (pg), CONTRACT-P5E §5.5: THE BUILD THAT NAMES NO CLIENT MEMORY. Everything it + // reads is the record: the archive create_shader_state carried, the three binding tails + // set_program_bindings carried, and the descriptor. Nothing here resolves a frontend + // identity, probes the client allocator or holds a SharedPtr to a frontend object - the + // three absences rule F is made of. + void BackendProgramObjectImpl::SyncToBackendByHandle(MG_Pipe::MGPipeHandle cso) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + const auto* record = PipeShaderCsoRecordForHandle(cso); + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: shader CSO {%u, %u} has no applier record, so no driver " + "program can be built for it", + cso.Slot, cso.Gen); + return; + } + // THE ARCHIVE IS THE RECORD'S OR THE BUILD DOES NOT HAPPEN. A record with no archive + // is one the transport arm never filled - a monolith create that reached a handle + // arm - and building from an empty reflection would produce a program with no + // uniforms and no modules, i.e. a black screen with no marker anywhere. The + // create's own trip wire already refuses a record that declares no blobs AND + // carries no artefacts (PipeApply.cpp); this is the same statement one level later, + // where the twin can name the handle. + if (!record->Archive) { + MGLOG_E_ONCE("MGPipe: shader CSO {%u, %u} carries no server-owned archive; its " + "create_shader_state was emitted by the monolith arm and this is " + "the handle arm, so there is nothing to build from", + cso.Slot, cso.Gen); + m_backendProgramUsable = false; + return; + } + SyncToBackendFromSource(ProgramArchiveSource::FromRecord(cso, *record)); + // The two server-owned keys the draw path's clean condition reads instead of + // GetLinkVersion()/GetImageUnitVersion(). BindingsSerial is the second because a + // glUniform1i on an IMAGE uniform is baked into the generated ESSL rather than + // re-issued per draw - the record that carries it must therefore force a rebuild, + // exactly as m_imageUnitVersion used to. + m_syncedShaderCsoSerial = record->Serial; + m_syncedBindingsSerial = record->BindingsSerial; + } +#endif + namespace { // The GL name of the array element that lives at `location`, given the reflection // name reported for it. Reflection reports one name per UNIFORM ("goku[0]") but @@ -8246,7 +14168,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // repeatedly; this turns it back into "goku[k]". Anything that is not an array // (or whose base location cannot be resolved) comes back unchanged, so the only // behaviour that moves is the array case. - String SubscriptUniformNameForElement(const MG_State::GLState::ProgramObject& program, const String& name, + String SubscriptUniformNameForElement(const ProgramBuildSource& program, const String& name, Uint location) { if (name.size() < 3 || name.compare(name.size() - 3, 3, "[0]") != 0) return name; const Int base = program.GetUniformLocation(name); @@ -8261,13 +14183,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // (BindCurrentProgramWithResources) never issues glGetUniformBlockIndex / // glGetUniformLocation string queries; block-to-binding-point assignments are // program state and only need to be established here. - void BackendProgramObjectImpl::CacheResourceLocations( - const SharedPtr& stateProgramObject) { + void BackendProgramObjectImpl::CacheResourceLocations(const ProgramBuildSource& src) { m_globalUboBackendBlockIndex = -1; m_globalUboBackendBlockSize = 0; m_lastUploadedGlobalUboVersion = ~0u; m_globalUboRingAllocation = {}; - if (stateProgramObject->GetUBOSize() > 0) { + if (src.GetUBOSize() > 0) { const Uint blockIndex = g_GLESFuncs.glGetUniformBlockIndex(m_backendProgramId, MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME); if (blockIndex != GL_INVALID_INDEX) { @@ -8284,16 +14205,16 @@ namespace MobileGL::MG_Backend::DirectGLES { } } else { MGLOG_W_ONCE("Program %u has frontend global UBO storage, but backend has no %s block.", - stateProgramObject->GetExternalIndex(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME); + src.GetExternalIndex(), MG_Util::ShaderTranspiler::GLOBAL_UBO_NAME); } } - const Int uboCount = stateProgramObject->GetActiveUniformBlocksCount(); + const Int uboCount = src.GetActiveUniformBlocksCount(); m_uniformBlockBackendIndices.assign(static_cast(std::max(uboCount, 0)), -1); Uint lastUBOBinding = 0; // binding 0 is reserved for the global UBO for (Int i = 0; i < uboCount; ++i) { ++lastUBOBinding; - const auto& name = stateProgramObject->GetUniformBlockName(static_cast(i)); + const auto& name = src.GetUniformBlockName(static_cast(i)); const GLuint backendBlkIdx = g_GLESFuncs.glGetUniformBlockIndex(m_backendProgramId, name.c_str()); if (backendBlkIdx == GL_INVALID_INDEX) { // Either eliminated as unused, or an SSBO block (frontend reflection @@ -8303,16 +14224,16 @@ namespace MobileGL::MG_Backend::DirectGLES { m_uniformBlockBackendIndices[static_cast(i)] = static_cast(backendBlkIdx); g_GLESFuncs.glUniformBlockBinding(m_backendProgramId, backendBlkIdx, lastUBOBinding); MGLOG_D("CACHE prog=%u beProg=%u blk[%d]='%s' beIdx=%u -> bePoint=%u", - stateProgramObject->GetExternalIndex(), m_backendProgramId, i, name.c_str(), backendBlkIdx, + src.GetExternalIndex(), m_backendProgramId, i, name.c_str(), backendBlkIdx, lastUBOBinding); } m_samplerUniformBindings.clear(); - const Uint maxUniformLoc = stateProgramObject->GetMaxUniformLocation(); + const Uint maxUniformLoc = src.GetMaxUniformLocation(); for (Uint loc = 0; loc <= maxUniformLoc; ++loc) { - const auto& name = stateProgramObject->GetUniformName(loc); + const auto& name = src.GetUniformName(loc); if (name.empty()) continue; - const GLenum uniformType = stateProgramObject->GetUniformType(loc); + const GLenum uniformType = src.GetUniformType(loc); if (IsImageUniformType(uniformType)) { // ES image units come exclusively from the layout(binding=N) qualifier // (preserved in the transpiled ESSL); glUniform1i on an image uniform @@ -8328,7 +14249,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // Address each element by its own name instead; the frontend already // reserves one location per element, so the element index is the distance // from the array's base location. - const String elementName = SubscriptUniformNameForElement(*stateProgramObject, name, loc); + const String elementName = SubscriptUniformNameForElement(src, name, loc); const Int backendLoc = g_GLESFuncs.glGetUniformLocation(m_backendProgramId, elementName.c_str()); if (backendLoc < 0) continue; SamplerUniformBinding binding; @@ -8449,16 +14370,143 @@ namespace MobileGL::MG_Backend::DirectGLES { m_backendSamplerId = 0; } +#if MOBILEGL_PIPE_PUSH + void BackendSamplerObject::SyncToBackend( + const SharedPtr& stateSamplerObject, + MG_Pipe::MGPipeHandle pushedCso) { +#else void BackendSamplerObject::SyncToBackend( const SharedPtr& stateSamplerObject) { +#endif #ifdef TRACY_ENABLE ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (!stateSamplerObject) { +#if MOBILEGL_PIPE_PUSH + // A CSO TWIN HAS NO FRONTEND OBJECT (P4a fable seam F-4, ResolveSamplerCsoTwin): + // the content-addressed handle is its identity and the applier's record its + // only authority, so a null object beside a live handle is the record arm and + // not the pre-P4a error. Everything below that names the object is guarded on + // it; the pull build's text is the three lines the #if brackets. + if (MG_Pipe::MGPipeHandleIsNull(pushedCso)) { +#endif MGLOG_E_ONCE("State sampler object is null, cannot sync to backend."); return; +#if MOBILEGL_PIPE_PUSH + } +#endif } + // P4a (D-F1): a SamplerObject is a pure 100-byte value with no driver-side per-object + // binding state, so its CSO is CONTENT-ADDRESSED on the client at capacity 256 and + // two identical samplers share one record. What that means here is that the record's + // server-owned Serial, not the frontend object's version, is what says the values + // moved - and that the values themselves cross byte for byte INCLUDING + // borderColorForm, which is why all four border comparands below are still compared. + // + // The whole arm choice is a preprocessor #if/#else so the PULL build's text is the + // pre-P4a text token for token (D-P). +#if MOBILEGL_PIPE_PUSH + const SamplerParameters* pushedParams = nullptr; + // The GL name for the two log lines below, or 0 for a CSO twin, which has no object + // to name (F-4): the handle in the same line is its name. + const Uint samplerName = stateSamplerObject ? stateSamplerObject->GetExternalIndex() : 0u; + if (SamplerSubsystemEnabled()) { + // THE HANDLE COMES FROM THE CALLER, NOT FROM THIS TWIN'S REGISTRY, and the + // difference is the seam D's verification round found on the integrated tree. + // + // v2 asked g_backendSamplerObjects.HandleOf(object) - the twin's own identity + // handle, minted off the frontend lifetime id - and looked the record up by it. + // That can only ever miss: a SamplerCso is CONTENT-ADDRESSED on the client + // (D-F1, ID-14/ID-17), its handles come out of MGPipeSlots().Allocate keyed on a + // parameter hash, and nothing ever emits a create_sampler_state at an identity + // handle. On the integrated tree every glBindSampler-driven parameter set was + // therefore refused and the driver sampler kept its defaults - two integration + // scenarios red, and the twin's own comment below (content-addressed, the + // record's Serial is the authority) already said why it could not work. + // + // The carried fact is MGPipeApplier().BoundSamplerStates[unit], written by the + // client at bind_sampler_states; the caller that knows the unit passes it as + // `pushedCso`. See the declaration for why the parameter is push-only. + if (!MG_Pipe::MGPipeHandleIsNull(pushedCso)) { + const auto* record = PipeSamplerCsoRecordForHandle(pushedCso); + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: sampler %u has no applier record on the handle arm, so its " + "parameters cannot be pushed (handle {%u, %u})", + samplerName, pushedCso.Slot, pushedCso.Gen); + return; + } + if (m_isInitialized && m_syncedSamplerSerial != 0 && m_syncedSamplerSerial == record->Serial) { + MGLOG_D("Sampler parameters have not changed for sampler ID: %u, skipping sync.", + samplerName); + return; + } + m_syncedSamplerSerial = record->Serial; + pushedParams = &record->Params; + } else { + // NO HANDLE CARRIED. Two shapes, and they are told apart by whether this + // object is in the twin registry at all: + // + // * not registered -> a sampler this BACKEND minted for its own use (the + // raw-depth-fetch sampler, DirectGLES.cpp:207-218). The client has never + // seen it, no record can exist for it now or after any package lands, + // and the object IS the authority for server-owned state. Not a seam. + // * registered -> an application sampler whose caller did not carry the + // unit's handle. That is the E-side call-site gap; it is named once and + // the values are taken from the object, which in monolith are the very + // values the client content-addressed, so the picture stays right while + // the gap is visible rather than silent. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): the registration probe below resolves the + // sampler's handle by frontend identity - frontend-keyed twin resolution, + // named debt inside the scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + if (!MG_Pipe::MGPipeHandleIsNull( + g_backendSamplerObjects.HandleOf(stateSamplerObject.get()))) { + MGLOG_E_ONCE("MGPipe: sampler %u was synced without its unit's SamplerCso handle, so " + "the content-addressed record cannot be named and the object's own " + "parameters are used - the caller must pass " + "MGPipeApplier().BoundSamplerStates[unit]", + stateSamplerObject->GetExternalIndex()); + } + // Spelled exactly as the pre-handle arm below spells it (compare widened, + // assign narrowed) so the two cannot drift on a version past 65535. + const Uint currentSamplerVersion = stateSamplerObject->GetVersion(); + if (m_isInitialized && m_syncedSamplerVersion == currentSamplerVersion) { + MGLOG_D("Sampler parameters have not changed for sampler ID: %u, skipping sync.", + stateSamplerObject->GetExternalIndex()); + return; + } + m_syncedSamplerVersion = currentSamplerVersion; + pushedParams = &stateSamplerObject->GetAllSamplerParameters(); + } + } else { + // A CSO twin is only ever resolved on the handle arm (ResolveSamplerCsoTwin gates + // on SamplerSubsystemEnabled()), so a null object cannot reach the legacy body + // below; stated as a return rather than assumed, because that body dereferences + // it. + if (!stateSamplerObject) return; +#if !MOBILEGL_PIPE_LEGACY_MEMOS + // UNREACHABLE: ResolveSamplerSubsystemArm stops at its first call when the bit is + // clear and the pre-handle arm is not compiled. Kept, and kept loud. + MGLOG_E_ONCE("MGPipe: the sampler subsystem bit is clear and " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 removed the pre-handle sampler-version " + "memo, so this configuration has no arm at all"); + return; +#else + Uint currentSamplerVersion = stateSamplerObject->GetVersion(); + if (m_isInitialized && m_syncedSamplerVersion == currentSamplerVersion) { + MGLOG_D("Sampler parameters have not changed for sampler ID: %u, skipping sync.", + stateSamplerObject->GetExternalIndex()); + return; + } + + m_syncedSamplerVersion = currentSamplerVersion; + pushedParams = &stateSamplerObject->GetAllSamplerParameters(); +#endif + } +#else Uint currentSamplerVersion = stateSamplerObject->GetVersion(); if (m_isInitialized && m_syncedSamplerVersion == currentSamplerVersion) { MGLOG_D("Sampler parameters have not changed for sampler ID: %u, skipping sync.", @@ -8467,11 +14515,20 @@ namespace MobileGL::MG_Backend::DirectGLES { } m_syncedSamplerVersion = currentSamplerVersion; +#endif +#if MOBILEGL_PIPE_PUSH + MGLOG_D("Syncing sampler with backend ID %u to backend for state ID %u", m_backendSamplerId, samplerName); +#else MGLOG_D("Syncing sampler with backend ID %u to backend for state ID %u", m_backendSamplerId, stateSamplerObject->GetExternalIndex()); +#endif +#if MOBILEGL_PIPE_PUSH + const SamplerParameters& samplerParams = *pushedParams; +#else const auto& samplerParams = stateSamplerObject->GetAllSamplerParameters(); +#endif #define SYNC_SAMPLER_PARAM_IF_CHANGED(internalName, glName, type) \ if (m_cacheSamplerParameters.internalName != samplerParams.internalName) { \ @@ -8581,7 +14638,43 @@ namespace MobileGL::MG_Backend::DirectGLES { } Array g_boundSamplersCache; - StateBackendObjectRegistry g_backendSamplerObjects; + TwinRegistry g_backendSamplerObjects; + +#if MOBILEGL_PIPE_PUSH + // P4a fable seam F-4. THE TWIN FOR A CONTENT-ADDRESSED SamplerCso HANDLE, keyed by that + // handle and synced from its record - see the declaration for why the identity-keyed + // lookup it replaces could never hit. + BackendSamplerObject* ResolveSamplerCsoTwin(MG_Pipe::MGPipeHandle cso) { + if (MG_Pipe::MGPipeHandleIsNull(cso)) return nullptr; + // THE RECORD FIRST, before the table is touched: a handle with no record is a seam + // defect (the client minted and named a CSO it never described, or evicted one a + // standing set still names), and adopting a slot for it would leave a twin that + // syncs nothing. The census stem is the sampler family's. + const auto* record = PipeSamplerCsoRecordForHandle(cso); + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: sampler CSO {%u, %u} has no applier record on the handle arm, so no " + "driver sampler can be built for it and the unit keeps what it holds", + cso.Slot, cso.Gen); + return nullptr; + } + // The same slot table the identity twins live in: one allocator serves both handle + // families of this kind, so a content-addressed slot and an identity-minted slot can + // never coincide, and the generation discipline (forward = recycle, backward = + // refused) is what retires a twin whose CSO the client's LRU evicted and re-minted. + auto* slot = g_backendSamplerObjects.GetOrCreateByHandle(cso); + if (slot == nullptr) { + MGLOG_E_ONCE("MGPipe: sampler CSO {%u, %u} cannot be adopted on the handle arm (the slot's " + "live generation is %u), so no driver sampler is built for it", + cso.Slot, cso.Gen, g_backendSamplerObjects.LiveGenAt(cso.Slot)); + return nullptr; + } + if (!*slot) *slot = MakeShared(); + // Serial-gated inside: a CSO whose record did not move since this twin last synced + // costs the record lookup above and one compare. + (*slot)->SyncToBackend(nullptr, cso); + return slot->get(); + } +#endif } // namespace SamplerImpl namespace RenderbufferImpl { @@ -8619,6 +14712,31 @@ namespace MobileGL::MG_Backend::DirectGLES { g_GLESFuncs.glBindRenderbuffer(GL_RENDERBUFFER, m_backendRBOId); } + // P4a (D-D2): the four values BackendRenderbufferObject::SyncToBackend allocates from, + // spelled once per arm. Macros for the reason the texture-parameter block gives: in the + // PULL build each expands to the pre-P4a expression at its original site, so that + // build's tokens and its symbol sizes are unchanged (D-P). + // + // ARCHITECTURE.md:130 keeps the renderbuffer an INDEPENDENT frontend class while giving + // it the same discriminated MGPResourceDesc a texture gets, and this is where that pays: + // the same four fields, read out of the same record type, with Desc.Target telling the + // applier which of the three per-kind tables the record lives in. +#if MOBILEGL_PIPE_PUSH +#define MGB_RBO_FORMAT \ + (pushedRecord != nullptr ? static_cast(pushedRecord->Desc.InternalFormat) \ + : stateRBOObject->GetInternalFormat()) +#define MGB_RBO_WIDTH (pushedRecord != nullptr ? static_cast(pushedRecord->Desc.Width) : static_cast(stateRBOObject->GetWidth())) +#define MGB_RBO_HEIGHT \ + (pushedRecord != nullptr ? static_cast(pushedRecord->Desc.Height) : static_cast(stateRBOObject->GetHeight())) +#define MGB_RBO_SAMPLES \ + (pushedRecord != nullptr ? static_cast(pushedRecord->Desc.Samples) : static_cast(stateRBOObject->GetSamples())) +#else +#define MGB_RBO_FORMAT stateRBOObject->GetInternalFormat() +#define MGB_RBO_WIDTH static_cast(stateRBOObject->GetWidth()) +#define MGB_RBO_HEIGHT static_cast(stateRBOObject->GetHeight()) +#define MGB_RBO_SAMPLES static_cast(stateRBOObject->GetSamples()) +#endif + void BackendRenderbufferObject::SyncToBackend( const SharedPtr& stateRBOObject) { #ifdef TRACY_ENABLE @@ -8632,6 +14750,51 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_D("Syncing RBO with backend ID %u to backend for state ID %u", m_backendRBOId, stateRBOObject->GetExternalIndex()); +#if MOBILEGL_PIPE_PUSH + const MG_Pipe::MGPipeResourceRecord* pushedRecord = nullptr; + if (TextureResourceSubsystemEnabled()) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §5.4): the renderbuffer's handle is resolved by + // frontend identity below - frontend-keyed twin resolution, named debt inside + // the scope - P3b/P4b rekeys the registry onto handles. + const MG_Pipe::MGPipeFrontendKeyedRegistryScope frontendKeyedRegistry; +#endif + // MONOLITH GLUE, named as such: the Renderbuffer handle of an object this + // backend still arrives holding. Under a real split it rides in the payload. + const MG_Pipe::MGPipeHandle res = g_backendRenderbufferObjects.HandleOf(stateRBOObject.get()); + pushedRecord = PipeRenderbufferRecordForHandle(res); + if (pushedRecord == nullptr) { + MGLOG_E_ONCE("MGPipe: renderbuffer %u has no applier record on the handle arm, so " + "its storage cannot be allocated from the pushed descriptor " + "(handle {%u, %u})", + stateRBOObject->GetExternalIndex(), res.Slot, res.Gen); + return; + } + if (m_isInitialized && m_syncedResourceSerial != 0 && + m_syncedResourceSerial == pushedRecord->Serial) { + MGLOG_D("RBO %u already initialized with matching parameters, skipping re-allocation.", + stateRBOObject->GetExternalIndex()); + return; + } + } else { +#if !MOBILEGL_PIPE_LEGACY_MEMOS + // UNREACHABLE: ResolveTextureResourceSubsystemArm stops at its first call when + // the bit is clear and the pre-handle arm is not compiled. Kept, and kept loud. + MGLOG_E_ONCE("MGPipe: the texture-resource subsystem bit is clear and " + "MOBILEGL_PIPE_LEGACY_MEMOS=0 removed the pre-handle renderbuffer " + "four-field cache, so this configuration has no arm at all"); + return; +#else + if (m_isInitialized && m_cacheInternalFormat == stateRBOObject->GetInternalFormat() && + m_cacheWidth == stateRBOObject->GetWidth() && m_cacheHeight == stateRBOObject->GetHeight() && + m_cacheSamples == stateRBOObject->GetSamples()) { + MGLOG_D("RBO %u already initialized with matching parameters, skipping re-allocation.", + stateRBOObject->GetExternalIndex()); + return; + } +#endif + } +#else if (m_isInitialized && m_cacheInternalFormat == stateRBOObject->GetInternalFormat() && m_cacheWidth == stateRBOObject->GetWidth() && m_cacheHeight == stateRBOObject->GetHeight() && m_cacheSamples == stateRBOObject->GetSamples()) { @@ -8639,14 +14802,30 @@ namespace MobileGL::MG_Backend::DirectGLES { stateRBOObject->GetExternalIndex()); return; } +#endif + + // A RE-STORAGE IS A REDEFINITION ON THE SAME DRIVER ID (P4a fable seam F-3, the + // pre-handle half): glRenderbufferStorage below re-allocates behind the name the + // framebuffer twins already attached, the frontend's renderbuffer setters bump no + // version and no framebuffer version sees them, so without this the FBO memo kept + // the widening masks of the storage the renderbuffer was attached with. Same + // generation a texture re-mint takes, for the same reason; the first allocation is + // not a redefinition. +#if MOBILEGL_PIPE_PUSH + // PUSH BUILDS ONLY, for the texture twin's reason (G1: the pull library stays + // byte-identical to the P4a baseline). + if (m_isInitialized) { + ++FramebufferImpl::g_attachmentBackendIdGeneration; + } +#endif Bind(); // Allocate storage - TextureInternalFormat internalFormat = stateRBOObject->GetInternalFormat(); - Int width = static_cast(stateRBOObject->GetWidth()); - Int height = static_cast(stateRBOObject->GetHeight()); - Int samples = static_cast(stateRBOObject->GetSamples()); + TextureInternalFormat internalFormat = MGB_RBO_FORMAT; + Int width = MGB_RBO_WIDTH; + Int height = MGB_RBO_HEIGHT; + Int samples = MGB_RBO_SAMPLES; GLenum glInternalFormat, glType, glFormat; TextureImpl::GenerateRenderbufferFormatInfo(internalFormat, &glInternalFormat, &glFormat, &glType); @@ -8675,8 +14854,8 @@ namespace MobileGL::MG_Backend::DirectGLES { MGLOG_E_ONCE("Renderbuffer %u storage allocation ran out of memory: %dx%d, samples=%d, format=%s", stateRBOObject->GetExternalIndex(), width, height, samples, MG_Util::ConvertGLEnumToString(glInternalFormat).c_str()); - if (MG_State::pGLContext) { - MG_State::pGLContext->RecordError( + if (MGB_CTX_LIVE) { + MGB_CTX->RecordError( ErrorCode::OutOfMemory, MakeUnique("DirectGLES", "BackendRenderbufferObject::SyncToBackend", "The ES driver could not allocate the renderbuffer storage.")); @@ -8688,12 +14867,270 @@ namespace MobileGL::MG_Backend::DirectGLES { m_cacheWidth = width; m_cacheHeight = height; m_cacheSamples = samples; +#if MOBILEGL_PIPE_PUSH + // Stamped in the same breath as the four cache members and AFTER the allocation, so + // an allocation the driver refused with GL_OUT_OF_MEMORY above leaves the twin + // describing what it actually holds. The deferred OOM report and its RecordError are + // untouched: the error still lands on whatever entry point triggered the sync, which + // is where the deferred model puts it. + if (pushedRecord != nullptr) m_syncedResourceSerial = pushedRecord->Serial; +#endif m_isInitialized = true; MGLOG_D("RBO %u sync completed. backend ID %u", stateRBOObject->GetExternalIndex(), m_backendRBOId); } +#undef MGB_RBO_FORMAT +#undef MGB_RBO_WIDTH +#undef MGB_RBO_HEIGHT +#undef MGB_RBO_SAMPLES + +#if MOBILEGL_PIPE_PUSH + // P5e (fb, CONTRACT-P5E.md §5.4): the same allocation, keyed on the renderbuffer HANDLE. + // + // THE FOUR VALUES WERE ALREADY THE RECORD'S at P4a (D-D2) - what the object form still + // needed the frontend for was the LOOKUP, `HandleOf(stateRBOObject.get())`, a + // client-allocator probe on the apply thread. On this arm the handle arrives from the + // attachment surface that named it, so nothing is probed and nothing is dereferenced. + // An OVERLOAD rather than a changed signature (the monolith glue keeps its tokens). + void BackendRenderbufferObject::SyncToBackendByHandle(MG_Pipe::MGPipeHandle renderbuffer) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + const MG_Pipe::MGPipeResourceRecord* const pushedRecord = + PipeRenderbufferRecordForHandle(renderbuffer); + if (pushedRecord == nullptr) { + MGLOG_E_ONCE("MGPipe: renderbuffer handle {%u, %u} has no applier record, so its " + "storage cannot be allocated from the pushed descriptor", + renderbuffer.Slot, renderbuffer.Gen); + return; + } + if (m_isInitialized && m_syncedResourceSerial != 0 && + m_syncedResourceSerial == pushedRecord->Serial) { + return; + } + + // A RE-STORAGE IS A REDEFINITION ON THE SAME DRIVER ID (P4a fable seam F-3): the + // allocation below re-defines the storage behind the name the framebuffer twins + // already attached, and no record of THEIRS moves for it, so the attachment memo + // has to be re-armed the same way a texture re-mint arms it. The first allocation + // is not a redefinition. + if (m_isInitialized) { + ++FramebufferImpl::g_attachmentBackendIdGeneration; + } + + Bind(); + + const auto internalFormat = static_cast(pushedRecord->Desc.InternalFormat); + const Int width = static_cast(pushedRecord->Desc.Width); + const Int height = static_cast(pushedRecord->Desc.Height); + const Int samples = static_cast(pushedRecord->Desc.Samples); + GLenum glInternalFormat, glType, glFormat; + TextureImpl::GenerateRenderbufferFormatInfo(internalFormat, &glInternalFormat, &glFormat, &glType); + + // Drain first, for the object form's reason: a driver that refuses the allocation + // (a multi-gigabyte renderbuffer is refused routinely) used to leave the twin + // claiming storage it does not have, and the attachment then rendered nowhere. + DebugImpl::ErrorLopper::Clear(); + if (samples > 0) { + const auto backendSamples = static_cast(ClampSamplesToBackendSupport( + GetRenderbufferFormatCapabilityTargetIndex(), internalFormat, glFormat, samples)); + g_GLESFuncs.glRenderbufferStorageMultisample(GL_RENDERBUFFER, backendSamples, glInternalFormat, + static_cast(width), + static_cast(height)); + } else { + g_GLESFuncs.glRenderbufferStorage(GL_RENDERBUFFER, glInternalFormat, static_cast(width), + static_cast(height)); + } + if (g_GLESFuncs.glGetError() == GL_OUT_OF_MEMORY) { + MGLOG_E_ONCE("Renderbuffer {%u, %u} storage allocation ran out of memory: %dx%d, " + "samples=%d, format=%s", + renderbuffer.Slot, renderbuffer.Gen, width, height, samples, + MG_Util::ConvertGLEnumToString(glInternalFormat).c_str()); + // NO RecordError HERE. The object form reports the OOM to the application + // through the live GLContext; on this arm there is no application on this side + // of the wire to report it to, and MGB_CTX->RecordError is itself one of the + // BARRIER-PULLED rows (FieldOwnership.def) that rule F forbids an unbarriered + // apply to touch. The client raises its own errors from its own validator. + } + DebugImpl::ErrorLopper::Clear(); + + m_cacheInternalFormat = internalFormat; + m_cacheWidth = width; + m_cacheHeight = height; + m_cacheSamples = samples; + m_syncedResourceSerial = pushedRecord->Serial; + m_isInitialized = true; + } +#endif // MOBILEGL_PIPE_PUSH - StateBackendObjectRegistry + TwinRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl + +#if MOBILEGL_PIPE_PUSH + // ===================================================================================== + // P5e SEAMS: DECLARED AND REFUSED HERE, BODIED BY THE FAMILY PACKAGES + // (MG_Remote/CONTRACT-P5E.md §4.2; BRIEF-P5E §1's "each such seam is a declared signature + // in c0e so both sides compile from day one") + // ===================================================================================== + // + // WHY THEY ARE ALL IN ONE BLOCK AND WHY THE BODIES ABORT. P5e lands as eight packages in + // eight parallel worktrees, and five of them are on opposite sides of these calls: tx2 + // writes SyncTextureToBackendByHandle and SyncMipmapsToBackendByHandle while fb's + // attachment sync and image sweep CALL them; fb writes the two SyncToBackendByHandle + // overloads while tx2's detach walk needs the reverse index behind them; id rekeys the + // registries the three resolvers read. A package that had to add its own declaration would + // collide with the package that added the other half, which is the merge trap P4a's + // contract package was written to avoid. + // + // So the signature is fixed HERE, once, and each family's commit replaces a body. The body + // ABORTS BY NAME rather than returning null or doing nothing: a resolver that answered null + // would render a blank draw and a sync that did nothing would render stale pixels, and both + // would be green lanes. Nothing calls any of them at this commit - every call site is still + // on the frontend overload beside it - so the abort is a link-time seam, not a runtime one. + // + // A package REPLACES the body in place and deletes the matching comment; it does not add a + // second definition elsewhere, or the linker's answer depends on link order. + namespace { + [[noreturn]] void MGPipeP5eSeamNotLanded(const char* name, const char* owner, + MG_Pipe::MGPipeHandle handle) { + MGLOG_F("MGPipe: Fatal{UnmigratedVerb, \"%s\"} - the P5e by-handle seam is declared by " + "package c0e and bodied by package %s; it was called for handle {%u, %u} " + "before that package landed", + name, owner, handle.Slot, handle.Gen); + std::abort(); + } + } // namespace + + namespace TextureImpl { + // ---- P5e (tx2): THE TEXTURE FAMILY'S BY-HANDLE ENTRY, CONTRACT-P5E §5.2 -------------- + // + // SamplerImpl::ResolveSamplerCsoTwin is the shape (Managers.cpp, the sampler half was + // already run-ahead clean on its record arm and this is that shape brought to the texture + // half): RECORD FIRST, twin by GetOrCreateByHandle, three syncs driven from the record, + // no frontend touch and no allocator probe anywhere on the path. + // + // WHAT THE FRONTEND ENTRY PAYS AND THIS ONE DOES NOT. SyncTextureObjectToBackend takes a + // by-VALUE copy of the twin and does a second Find at the tail, because its registry's + // Find returns a reference INTO an open-addressed map that a NESTED sync (a view syncing + // its storage texture) can rehash out from under it. A slot-table entry is an ARRAY + // ELEMENT: a nested GetOrCreateByHandle can only grow the vector, so the answer is + // re-INDEXED at the tail rather than pinned by a refcount, and the copy is a refcount + // this arm does not pay. + // + // A null return is a NAMED decline and never a mint: no record (the client named a + // texture it never created, or one whose resource_destroy has already applied), or a slot + // whose live generation is ahead of the handle's. A caller binds nothing and the unit + // keeps what it holds. + SharedPtr& SyncTextureToBackendByHandle(MG_Pipe::MGPipeHandle texture, + Bool imageBindableStorageRequired) { +#ifdef TRACY_ENABLE + ZoneScopedC(TRACY_ZONECOLOR_BACKEND); +#endif + // The one storage every decline arm can return a reference to. Never written. + static SharedPtr s_noTwin{}; + s_noTwin = nullptr; + + const auto* record = PipeTextureRecordForHandle(texture); + if (record == nullptr) { + MGLOG_E_ONCE("MGPipe: texture {%u, %u} has no applier resource record on the handle " + "arm, so no driver texture can be built for it and the caller keeps " + "what it holds", + texture.Slot, texture.Gen); + return s_noTwin; + } + auto* slot = g_backendTextureObjects.GetOrCreateByHandle(texture); + if (slot == nullptr) { + MGLOG_E_ONCE("MGPipe: texture {%u, %u} cannot be adopted on the handle arm (the slot's " + "live generation is %u), so no driver texture is built for it", + texture.Slot, texture.Gen, g_backendTextureObjects.LiveGenAt(texture.Slot)); + return s_noTwin; + } + if (!*slot) *slot = MakeShared(); + // THE HANDLE IS NOTED BEFORE ANY SYNC RUNS. It is what the three prologues read + // instead of probing the client allocator, and what selects the by-handle arm inside + // them, so it has to be in place before the first of them is called. + (*slot)->NotePushedSyncHandle(texture); + + const SharedPtr twin = *slot; + if (imageBindableStorageRequired) { + twin->RequireImageBindableStorageByHandle(texture, *record); + } + static const SharedPtr kNoFrontendTexture{}; + twin->SyncTextureParamsToBackend(kNoFrontendTexture); + twin->SyncBuiltinSamplerToBackend(kNoFrontendTexture); + twin->SyncMipmapsToBackend(kNoFrontendTexture); + // The storage sync may RE-MINT the driver texture, which discards every parameter the + // two calls above just pushed - the frontend entry's argument, unchanged. + if (twin->NeedsParameterResync()) { + twin->SyncTextureParamsToBackend(kNoFrontendTexture); + twin->SyncBuiltinSamplerToBackend(kNoFrontendTexture); + } + // ONE re-index, and only because a nested adopt may have GROWN the vector; nothing on + // this arm erases a live slot, so the entry itself cannot have gone. + auto* refreshed = g_backendTextureObjects.FindByHandle(texture); + if (refreshed != nullptr && *refreshed) { + return *refreshed; + } + s_noTwin = twin; + return s_noTwin; + } + + void BackendTextureObject::SyncMipmapsToBackendByHandle(MG_Pipe::MGPipeHandle texture) { + // The STORAGE half alone, for fb's attachment sync and the image sweep: an attachment + // needs its levels on the driver, not its sampler parameters (§5.4). The handle note + // is what routes the body below onto the record. + NotePushedSyncHandle(texture); + static const SharedPtr kNoFrontendTexture{}; + SyncMipmapsToBackend(kNoFrontendTexture); + } + +#if MOBILEGL_PIPE_PUSH + // P5e (tx2), CONTRACT-P5E §5.2. See the declaration for the clause-by-clause derivation + // and for why BOTH halves of each resync pair are read here. + Bool BackendTextureObject::IsDrawSyncCleanByRecord(MG_Pipe::MGPipeHandle res, + const MG_Pipe::MGPipeResourceRecord& record) const { + (void)res; + // SyncMipmapsToBackend's own early-out, verbatim. + if (!m_isInitialized || m_syncedResourceSerial == 0 || m_syncedResourceSerial != record.Serial || + !record.PendingUploads.empty()) { + return false; + } + // SyncTextureParamsToBackend's (ResolvePushedTextureParams' gate). + if (m_syncedParamsSerial != record.ParamsSerial || record.Params.ForceResync != 0 || + m_forceTextureParamsResync) { + return false; + } + // SyncBuiltinSamplerToBackend's (ResolvePushedBuiltinSampler's gate). A texture whose + // params have never been set has no built-in sampler to push and that prologue + // declines SILENTLY - so a zero ParamsSerial is clean here for the same reason, and + // the CSO lookup below is only made when there is a CSO to look up. + if (record.ParamsSerial != 0) { + const MG_Pipe::MGPipeHandle builtin = record.Params.BuiltinSampler; + if (m_syncedBuiltinSampler != builtin) return false; + const auto* cso = PipeSamplerCsoRecordForHandle(builtin); + if (cso == nullptr || m_syncedBuiltinSamplerSerial != cso->Serial) return false; + if (record.Params.SamplerResync != 0 || m_forceSamplerResync) return false; + } else if (m_forceSamplerResync) { + return false; + } + // The storage-kind restriction the frontend gate carries, from the descriptor: a + // buffer texture's backing store can move without any serial above noticing. + return static_cast(record.Desc.StorageKind) == TextureStorageType::Mipmap; + } +#endif + } // namespace TextureImpl + + // FramebufferImpl::BackendFramebufferObject::SyncToBackendByHandle and + // RenderbufferImpl::BackendRenderbufferObject::SyncToBackendByHandle LANDED with package fb + // (P5e); their bodies are beside the object forms they overload, where the attachment walk + // and the storage allocation they share can be read side by side. + + // PrgramImpl's seam is GONE, not stubbed: package pg wrote + // BackendProgramObjectImpl::SyncToBackendByHandle's real body beside the frontend overload + // it is the twin of (above, with the two ProgramArchiveSource heads). A second definition + // here would make the linker's answer depend on link order, which is the one failure this + // block's own comment was written to prevent. + +#endif // MOBILEGL_PIPE_PUSH } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/Managers.h b/MobileGL/MG_Backend/DirectGLES/Managers.h index 6603f2cc3..60ce40db0 100644 --- a/MobileGL/MG_Backend/DirectGLES/Managers.h +++ b/MobileGL/MG_Backend/DirectGLES/Managers.h @@ -16,6 +16,16 @@ #include #include #include +#include "SlotTables.h" +#if MOBILEGL_PIPE_PUSH +// P3a: the vertex-input payload views the handle arm of the VAO twin consumes. +#include +// 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 +#endif namespace MobileGL::MG_Backend::DirectGLES { String EmulateBaseInstanceInVertexShader(String source, GLenum shaderType); @@ -267,7 +277,35 @@ namespace MobileGL::MG_Backend::DirectGLES { EndViewportRoutingPasses(passCount); } - template + // The backend twin table. Two arms live behind this one interface (ARCHITECTURE.md 9.6 - + // after Track H the MOBILEGL_PIPE_PUSH bitmap alone is not a valid A/B, because with a bit + // clear the backend would still be running the re-keyed code): + // + // legacy (MOBILEGL_PIPE_LEGACY_MEMOS): UnorderedMap keyed on the + // frontend heap ADDRESS, with a weak_ptr per entry as the ABA defence, an erase + // inside Find, and a garbage sweep as the only death signal. Pre-P2 code verbatim. + // handles (MOBILEGL_PIPE_PUSH and kMGPipeSubsystemEsprytSlots): BackendSlotTable, keyed + // on MGPipeHandle{Slot, Gen}. See SlotTables.h for what that buys. + // + // Which arm runs is fixed once per process (EsprytSlotTablesEnabled()): the two arms hold + // their twins in different containers, so a mid-run flip would strand every twin already + // built. Every call site below this class is arm-agnostic and unchanged. + // + // The kind is a template parameter ONLY in the push build. G1 requires the pull build's + // symbol set to be byte-for-byte the pre-P2 one, and a third template argument changes + // every instantiation's mangled name - so in the pull build the parameter, like the arm it + // selects, does not exist. The macro below spells that one difference; it is #undef'd + // straight after the class, and the twelve declaration and definition sites name the + // registry through the TwinRegistry alias instead, which swallows the kind in the pull + // build. (An alias template may have a parameter it does not use, and an alias emits no + // symbol of its own, so the pull build's mangled names are unchanged.) +#if MOBILEGL_PIPE_PUSH +#define MGB_TWIN_KIND_PARAM , MG_Pipe::MGPipeKind kKind +#else +#define MGB_TWIN_KIND_PARAM +#endif + + template class StateBackendObjectRegistry { public: @@ -286,12 +324,26 @@ namespace MobileGL::MG_Backend::DirectGLES { using BackendMap = UnorderedMap; using iterator = typename BackendMap::iterator; using const_iterator = typename BackendMap::const_iterator; +#if MOBILEGL_PIPE_PUSH + using SlotTable = BackendSlotTable; +#endif BackendPtr& GetOrCreate(const StatePtr& stateObj) { MOBILEGL_ASSERT(stateObj != nullptr, "State object must not be null"); +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + // The slot table arms the teardown sentinel itself, at its own first + // insertion (D13; SlotTables.h) - so a table used outside a registry arms + // it too, which is right: it is the twin, not the registry, that owns the + // driver id a guarded destructor exists for. + return m_slotTable.GetOrCreate(stateObj); + } +#endif // Twin creation is the moment a driver-owned id starts needing a guarded - // destructor; cold path, so the once-guard costs nothing per draw. + // destructor; cold path, so the once-guard costs nothing per draw. It is armed + // here, at the first insertion - a destructor hook on the table itself is wrong + // for the reason spelled out above InProcessTeardown(). EnsureProcessTeardownSentinel(); // Sweep BEFORE the entry reference below exists: the map is open-addressed and an // erase relocates the rest of the probe cluster, so collecting once that reference @@ -324,14 +376,24 @@ namespace MobileGL::MG_Backend::DirectGLES { return entry.backend; } - // Null when no live state object owns this key. The result points into the map, so - // it stays valid only until the next GetOrCreate/Find/CollectGarbage on this registry. - // Take that literally, including for Find: the map is open-addressed and erases by - // shifting the rest of the probe cluster into the hole, so an erase relocates entries - // OTHER than the erased one - and Find erases, whenever it lands on a key whose state - // object has expired. Callers that need the twin across another registry call must copy - // the BackendPtr out (or keep only the pointee, which is heap-allocated and never moves). + // Null when no live state object owns this key. + // + // On the HANDLE arm the result is a stable array element: only a GetOrCreate that grows + // the table can move it, and nothing else on the table invalidates it. + // + // On the LEGACY arm the result points into the map, so it stays valid only until the + // next GetOrCreate/Find/CollectGarbage on this registry. Take that literally, including + // for Find: the map is open-addressed and erases by shifting the rest of the probe + // cluster into the hole, so an erase relocates entries OTHER than the erased one - and + // Find erases, whenever it lands on a key whose state object has expired. Callers that + // need the twin across another registry call must copy the BackendPtr out (or keep only + // the pointee, which is heap-allocated and never moves). BackendPtr* Find(StateObject* stateObj) { +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + return m_slotTable.Find(stateObj); + } +#endif const auto entryIt = m_entries.find(stateObj); if (entryIt == m_entries.end()) { return nullptr; @@ -352,16 +414,176 @@ namespace MobileGL::MG_Backend::DirectGLES { iterator end() { return m_entries.end(); } const_iterator end() const { return m_entries.end(); } +#if MOBILEGL_PIPE_PUSH + // The {slot, gen} this object's twin is keyed on, or the null handle. This is what a + // backend memo stores instead of a raw pointer, a GL name or a bare lifetime id. + MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const { + if (EsprytSlotTablesEnabled()) { + return m_slotTable.HandleOf(stateObj); + } + return MG_Pipe::kMGPipeNullHandle; + } + + // The twin at a handle, or null when the slot is free or its Gen has moved on. This is + // the lookup a backend memo that already holds a handle wants: no lifetime-id probe. + BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) { + if (EsprytSlotTablesEnabled()) { + return m_slotTable.FindByHandle(handle); + } + return nullptr; + } + + // 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); + } + + // P5c (hd): the two halves of SlotTables.h's state note, forwarded. A caller holding + // both the record's handle and the frontend object (the record-driven sync) notes the + // object so a later handle-only resolution can reach it without the client allocator. + // + // P5e (id): under MOBILEGL_PIPE_PUSH rather than MOBILEGL_BUILD_DISAGGREGATED, because + // the re-typed ForEachLive's caller resolves its object through StateForHandle in the + // push-monolith build too. Each half is a named Fatal from an unbarriered apply + // (SlotTables.h); on the legacy arm there is no note and StateForHandle answers null, + // which is the answer the legacy walk's own weak_ptr test already gives. + void NoteStateForHandle(MG_Pipe::MGPipeHandle handle, const StatePtr& stateObj) { + if (EsprytSlotTablesEnabled()) { + m_slotTable.NoteStateForHandle(handle, stateObj); + } + } + StatePtr StateForHandle(MG_Pipe::MGPipeHandle handle) const { + if (EsprytSlotTablesEnabled()) { + return m_slotTable.StateForHandle(handle); + } + return nullptr; + } + + // NO ReleaseByHandle HERE THROUGH P5, AND THAT WAS A DECISION (review M-4). The death + // half of GetOrCreateByHandle existed only for a kind whose announcement is its own + // destroy CALL rather than the shared death notice - which was the BUFFER family + // (BackendBufferResourceTable::ReleaseByHandle, SlotTables.h, called from + // resource_destroy) and none of the five kinds this registry serves: every one of + // them died through DestroyByLifetimeId below, because P4a added no server-side + // destroy arm for a texture, a renderbuffer, a framebuffer, a sampler CSO or a shader + // CSO. P5c (ct) is the commit that gives the wrapper its caller: object_death carries + // the dead object's HANDLE on the wire (CONTRACT-P5C.md §5.2), and the static + // ReleaseByHandle below is what the sink's per-kind dispatch calls. + + // P5c (ct), CONTRACT-P5C.md §5.2: the wrapper M-4 below deferred, given its caller by + // object_death. The record carried the handle, so the release is keyed by it and the + // client's allocator is never asked from this side (rule E); every holder of the kind + // lets go, exactly as DestroyByLifetimeId walks them. What this does NOT do is the + // allocator Free the notice arm performs - the slot's owner is the client, which + // already returned it after the record went out. STATIC for the same reason + // DestroyByLifetimeId is: a death is about an object, not a table instance. + static Bool ReleaseByHandle(MG_Pipe::MGPipeHandle handle) { + if (EsprytSlotTablesEnabled()) { + return SlotTable::ReleaseTwinByHandle(handle); + } + return false; + } + + // P2 step e2. STATIC, because a death notice is about an object and not about a + // registry instance: it is answered by EVERY table of this kind that exists - this + // registry's own, and any by-value copy of it a fixture or a context reset is holding + // (SlotTables.h explains the holder list and why one holder was a leak). + // + // The legacy arm cannot answer this at all - its key is the frontend heap ADDRESS and + // the object is already gone by the time the notice arrives - so there it is a no-op + // and the garbage sweep stays its only death signal. That asymmetry is not an + // oversight: it is the A/B the compile-time arm exists to make measurable + // (ARCHITECTURE.md 9.6), and announced-versus-discovered death is one of the things + // being measured. + static Bool DestroyByLifetimeId(Uint64 lifetimeId) { + if (EsprytSlotTablesEnabled()) { + return SlotTable::OnFrontendObjectDestroyed(lifetimeId); + } + return false; + } + + // P5e (id), CONTRACT-P5E §4.1: fn(MGPipeHandle, const BackendPtr& twin) over every live + // entry of the HANDLE ARM, and of that arm only. + // + // IT NO LONGER SERVES THE LEGACY ARM, and that is a narrowing rather than a loss. The + // legacy map is keyed by the raw frontend address and has no handle to hand over - a + // synthesised null one would be a lie the callee could not tell from a real answer - + // and its one caller (ScopedDetachedTextureFramebufferAttachments) has always had its + // own `#if MOBILEGL_PIPE_LEGACY_MEMOS` begin()/end() walk beside the call, because the + // legacy entry needs its stateRef.expired() test done by hand. So the arm check here + // was answering a question no caller asked, and leaving it would have meant inventing a + // second signature for a walk the P5e rule exists to delete. + template + void ForEachLive(Fn&& fn) const { + if (EsprytSlotTablesEnabled()) { + m_slotTable.ForEachLive(fn); + } + } +#endif + + // The seven DirectGLES.cpp call sites drive the LEGACY arm and nothing else. On the + // handle arm death is announced by the frontend object's destructor + // (MG_State/GLState/StateObjectDeathNotice.h), so there is no garbage to collect on a + // tick, the slot table has no collector to forward to, and this is the predicted + // branch plus a return - which is how ROADMAP.md:18's "delete the GC" is delivered + // without deleting the legacy arm's own collector while that arm is still compiled + // beside it. void CollectGarbageIfNeeded() { +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + return; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS ++m_gcTick; if (m_gcTick < kGCInterval) { return; } CollectGarbage(); m_gcTick = 0; +#endif } - void CollectGarbageNow() { CollectGarbage(); } + // Pre-P2 API, kept for the legacy arm. On the handle arm there is nothing it could + // collect: a twin leaves with its object's death notice, and a notice dropped during + // process teardown is a deliberate leak (SlotTables.h), not garbage awaiting a call. + void CollectGarbageNow() { +#if MOBILEGL_PIPE_PUSH + if (EsprytSlotTablesEnabled()) { + return; + } +#endif + CollectGarbage(); + } private: void CollectGarbage() { @@ -395,8 +617,166 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 m_gcTick = 0; Uint32 m_creationTick = 0; Bool m_isCollecting = false; +#if MOBILEGL_PIPE_PUSH + BackendSlotTable m_slotTable; +#endif }; +#undef MGB_TWIN_KIND_PARAM + + // One spelling for the twin registry at every declaration and definition site. In the push + // build the kind is the registry's third template argument; in the pull build the alias + // drops it, so the mangled name is the pre-P2 two-argument one. +#if MOBILEGL_PIPE_PUSH + template + using TwinRegistry = StateBackendObjectRegistry; +#else + template + using TwinRegistry = StateBackendObjectRegistry; +#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; + } + +#if MOBILEGL_PIPE_PUSH + // P5e (pg), CONTRACT-P5E.md §5.8 / ruling 1 (ID-81): THE HANDLE ARM'S SELECTOR for the + // program family. Transport AND the family bit, in that order and both required: + // + // * `Transport != Monolith` because the push-MONOLITH build keeps its frontend arms token + // for token - the verify comparator needs them, and it is what makes + // MOBILEGL_IPC_RUN_AHEAD=0 a pure wait-rule A/B on identical server code rather than a + // comparison of two different backends; + // * the family bit because the A/B that switches this family off has to switch off the + // arm too, not only the emission - a server reading records nobody sends would draw + // with no program at all. + // + // NOT LATCHED, unlike the four above: Transport is configuration read once at bring-up and + // ProgramSubsystemEnabled() already latches, so this is one load and one test, and a latch + // here would only hide which half answered. + inline Bool ProgramHandleArm() { + return MG_Config::Transport != MG_Config::TransportMode::Monolith && ProgramSubsystemEnabled(); + } +#endif + + // ---- 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); + + // P5e (fb): the texture's own TARGET, from its descriptor. The image-unit bind needs it + // (glBindImageTexture's layered/format rules are per target) and MGPImageView has no room + // for it - 24 bytes, no pad - so it is read off the resource record instead of being added + // to the wire. TextureTarget::Unknown when the handle names no live texture record, which + // every caller treats as "decline", never as a default. + MobileGL::TextureTarget PipeTextureTargetForHandle(MG_Pipe::MGPipeHandle res); + + // 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(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; @@ -436,6 +816,52 @@ namespace MobileGL::MG_Backend::DirectGLES { // AcquirePersistentMap or (FLUSH_EXPLICIT) publish only via FlushMappedRange. Uint64 CurrentBufferMutationEpoch(); void BumpBufferMutationEpoch(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (vi), CONTRACT-P5E §5.1's first pin: how many bumps came from a thread other than + // the apply thread WHILE an apply thread was running, under a live transport. The + // record arm's clean gate stamps this counter and skips its probes while the stamp + // holds, so "who may move it" is a question the phase has to answer with a number + // rather than with a reading of the call graph. Expected 0 for any session; the bump + // itself is Fatal on that path under the strict lane, and the answer at this head is + // that the only non-apply-thread bump in the tree happens at bring-up, BEFORE the apply + // thread exists (BackendObject_DirectGLES::Initialize's RegisterBufferBackendOps), and + // is therefore not counted here. + Uint64 BufferMutationEpochBumpsOffTheApplyThread(); + + // ---- P5e (vi): THE TWO SOURCE DECISIONS OF THE DRAW'S BUFFER SYNC ------------------ + // + // CONTRACT-P5E §5.1 is, for this family, two substitutions and nothing else: the + // attribute walk's buffer set comes from st.VertexBuffers[Start..+Count) instead of the + // frontend VAO's GetAllAttributes(), and the index buffer comes from st.IndexBuffer.Res + // + IndexBufferSerial instead of the VAO's element slot. Both are lifted out of + // SyncNeccessaryBuffers' arms and given names HERE, and the reason is the red-once: + // these take the applier state AND NOTHING ELSE, so the only way to revert the + // substitution is to put a frontend read back inside one of them, and + // MG_Test/SanityTest.cpp drives exactly these two with a frontend VAO deliberately + // pointing somewhere else. A test that drove a copy of the decision instead would stay + // green through that revert, which is the fake-green ID-102 names. + // + // The ensure, the memo and the clean probe stay where they are: they need an ES context + // and are the integration lane's to exercise. + struct DrawVertexBufferRequest { + MG_Pipe::MGPipeHandle Res = MG_Pipe::kMGPipeNullHandle; + // MGPVertexBuffer::BindingIndex, carried for the memo's diagnostics only. + Uint32 BindingIndex = 0; + }; + // The DISTINCT buffers the draw's enabled attributes fetch from, deduped on {slot, gen}. + // A null Res is skipped: it is a disabled slot below the window's high-water mark, or a + // client-memory array whose bytes have no store to ensure (P8 stages those). + Uint ResolveDrawVertexBuffersFromRecord(const MG_Pipe::MGPipeApplierState& st, + DrawVertexBufferRequest* out, Uint capacity); + + struct DrawIndexBufferRequest { + MG_Pipe::MGPipeHandle Res = MG_Pipe::kMGPipeNullHandle; + // IndexBufferSerial, which joins the identity compare because this arm has no live + // frontend slot to re-read - see ResolvedDrawBuffers::iboSerial. + Uint64 Serial = 0; + }; + DrawIndexBufferRequest ResolveDrawIndexBufferFromRecord(const MG_Pipe::MGPipeApplierState& st); +#endif // The DirectGLES storage behind one frontend buffer. Owned (refcounted) by // the frontend BufferObject; immediate BufferBackendOps keep it current, so @@ -493,8 +919,213 @@ namespace MobileGL::MG_Backend::DirectGLES { // no map, and a respecification then has to retire the id rather than hand it // to glBufferData, which the driver would silently refuse. Bool immutableStorage = false; +#if MOBILEGL_PIPE_PUSH + // P3a: the client's shadow base as the last content-carrying resource call left + // it. The handle-shaped ops carry `shadow + offset` beside their record, so the + // base is recovered by subtracting the record's own offset once, here. + // + // IT IS A RAW POINTER INTO AN ALLOCATION THIS SIDE DOES NOT OWN, so its lifetime + // rule is written here and enforced at the three events that end it - a cached + // base with no invalidation is a use-after-free waiting for an ordinary call: + // + // * a content-carrying call (respecify / sub-data / flush-range) REFRESHES it; + // * an ORPHANING respecify (HasDefinedContent clear) CLEARS it, because that is + // also the call that resizes the shadow - reserve + resize reallocates and + // frees the old block - and it brings no replacement base; + // * a successful map_persistent CLEARS it, because the client then adopts the + // coherent pointer and drops the shadow (PipeResource::AdoptPersistentMap does + // clear() + shrink_to_fit()). For such a resource the bytes are persistentPtr. + // + // Every reader treats null as "no bytes to move". And any path that STILL HOLDS the + // frontend object - the ensure path does, because D-N keeps SyncPersistentMappedRange + // there for all of P3a - re-reads MappedData() instead of reading this, exactly as + // the legacy arm did; this member exists for the drains that have no object, which + // in P3a is the readback flush and the fp64 narrowing. + const Uint8* hostBytes = nullptr; +#endif }; +#if MOBILEGL_PIPE_PUSH + // P3a (D-A4): the SEVENTH Espryt slot table, and the first one keyed by a handle the + // CALL carried rather than one this backend minted off a frontend object's lifetime + // id. That is what discharges, for this kind, the debt SlotTables.h records against + // itself: GLESBufferResource stops hanging off PipeResource::m_backend and lives here + // instead, so the resource table is the server's own and a frontend heap reference is + // no longer part of resolving it. + // + // The StateObject parameter is BufferObject only because the template names one; not + // one member that touches it is instantiated on this table (no Find(StateObject*), no + // HandleOf, no ForEachLive), and the handle overloads never look at it. Death is + // announced by the family's own ResourceDestroy call, not by the shared death notice + // (D-L), and the slot is freed by the CLIENT after that call returns. + using BackendBufferResourceTable = + BackendSlotTable; + extern BackendBufferResourceTable g_backendBufferResources; + + // Resolved once per process and latched, exactly like EsprytSlotTablesEnabled() and + // for the same reason: the two arms hold GLESBufferResource in DIFFERENT containers - + // the legacy arm in the frontend object's PipeResource::m_backend, the handle arm in + // the table above - so an answer that changed mid-run would strand every resource + // already built and leak the driver ids they own. + Bool ResolveResourceSubsystemArm(); + // Same shape for the vertex-input family (bit 8), and separate because the two bits are + // separately clearable - but NOT independent, and the resolver says so out loud rather + // than half-running: bit 8 REQUIRES bit 7, because the vertex-input handle arm resolves + // every attribute's driver buffer id out of the resource slot table and only bit 7 puts + // twins there. `0x17f` (bit 8 on, bit 7 off) is therefore refused at arm resolution with + // a named MGLOG_E and runs the legacy vertex-input arm; `0x0ff` (bit 7 on, bit 8 off) is + // a real, supported A/B, because the legacy VAO walk reaches the handle arm through + // EnsureBufferResource's own dispatch. Both resolvers also answer + // MG_Config::Features.PipeLegacyMemos, so "the bit is clear and the legacy arm was taken + // away" is a named verdict instead of a silent legacy run. + Bool ResolveVertexInputSubsystemArm(); + + // INLINE for the reason SlotTables.h spells out at EsprytSlotTablesEnabled: both are + // consulted on the per-draw path (the VAO sync's gate, EnsureBufferResource, every + // buffer op), and out-of-line they would be a call through the PLT per consult. + inline Bool ResourceSubsystemEnabled() { + static const Bool enabled = ResolveResourceSubsystemArm(); + return enabled; + } + inline Bool VertexInputSubsystemEnabled() { + static const Bool enabled = ResolveVertexInputSubsystemArm(); + return enabled; + } + + // Resolve-or-create / resolve-only, by the handle the call carried. Neither touches + // MGPipeSlots(): the handle ARRIVED already minted by the side that owns minting. + GLESBufferResource* GetOrCreateBufferResourceForHandle(MG_Pipe::MGPipeHandle res); + GLESBufferResource* FindBufferResourceForHandle(MG_Pipe::MGPipeHandle res); + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd): the staged-coverage assertion (StagedShadowStore::RequireCoverage) for a + // read of the server shadow outside the upload ladders - the indirect command-byte + // resolver. A no-op for a base that is not this resource's server shadow. + void RequireStagedCoverage(GLESBufferResource& resource, const Uint8* hostBase, SizeT start, + SizeT end, const char* site); +#endif + + // MONOLITH GLUE, and named as such: the handle of a resource this backend is looking + // at through a frontend object, resolved through the client allocator's lifetime-id + // index. Every caller is a site P3a deliberately does NOT migrate - the SSBO / UBO / + // indirect / pack-PBO binding walks are dirty bits 15-17 and P4b's, and the index + // host mirror is P8's - so they still arrive holding the object. Under a real split + // neither the object nor its lifetime id exists on this side and every one of them + // has to receive the handle in a payload instead. + MG_Pipe::MGPipeHandle HandleOfBuffer(const MG_State::GLState::BufferObject* bufferObject); + + // TIER 1 OF THE THREE-TIER FLUSH LADDER, AS A PURE FUNCTION (P5 b1). + // + // `GL_MAP_INVALIDATE_RANGE_BIT` is not a hint, it is an ASSERTION THAT THE OLD BYTES + // ARE DEAD - and it is only true of the bytes this call is about to rewrite from the + // authoritative shadow. Managers.cpp:1125-1128 records what happens when it is not: + // widening the map to page bounds "looked free and was not - the widened bytes + // clobbered GPU-written data (an SSBO counter beside the app's SubData) with the stale + // shadow". It fails SILENTLY, unlike tier 3, which only stalls. + // + // WHY IT IS A FUNCTION NOW, AND WHY IT TAKES THE MAP RANGE SEPARATELY FROM THE QUEUED + // ONE. Under split the bytes are not re-read at every use any more: the server may not + // hold a pointer into the client's shadow at all (R-11), so `hostBase` becomes a + // SNAPSHOT taken into SEG_STAGE at emission, and the window between the snapshot and + // the apply is new. A snapshot that covers less than the map does is exactly the + // widening that drew blood, with a thread boundary instead of a page alignment as the + // cause - so the two extents are separate parameters and a disagreement returns 0 + // ("do not take tier 1"), which drops the range onto the staging ring and costs a copy + // rather than a corruption. + // + // Returns the glMapBufferRange access bits, or 0 when tier 1 must not be taken. + inline constexpr SizeT kEsprytInvalidateRangeMinBytes = 128u * 1024u; + constexpr GLbitfield InvalidateFlushAccessFor(SizeT queuedStart, SizeT queuedEnd, SizeT mapStart, + SizeT mapEnd, SizeT limit, SizeT storageSize) { + if (mapEnd <= mapStart) return 0u; + // THE WIDENING REFUSAL. Not >=, not "covers": exactly, in both directions. A map + // narrower than the queued range leaves bytes unwritten inside a range it has just + // declared dead, which is the same corruption read the other way round. + if (mapStart != queuedStart || mapEnd != queuedEnd) return 0u; + const SizeT size = mapEnd - mapStart; + const Bool wholeBuffer = mapStart == 0 && mapEnd == limit && limit == storageSize; + // A partial range below the threshold goes to the ring instead: the map's + // page-substitution fast path needs a page-coverable range to engage, and below it + // the driver falls back to waiting out the WAR hazard on the CPU. + if (!wholeBuffer && size < kEsprytInvalidateRangeMinBytes) return 0u; + return GL_MAP_WRITE_BIT | + (wholeBuffer ? GL_MAP_INVALIDATE_BUFFER_BIT : GL_MAP_INVALIDATE_RANGE_BIT); + } + + // The handle arms of the two draw-path entry points below. IsBufferDrawCleanByHandle + // asks the applier the same five questions IsBufferDrawClean asks the frontend object, + // with identical semantics (D-A4); EnsureBufferResourceForHandle is the ensure path + // driven by the applier's descriptor and the shadow base the call carried. + // + // `frontend` supplies the ONE question the applier's record cannot answer in P3a: an + // emulated (non-adopted) persistent map is written through its pointer with no call, so + // MGPipeResourceRecord::HasLiveHostWrites - the field that will carry it - is pinned + // false and the probe still has to ask the object. It retires with P5. See the long note + // at the definition; passing null means "no live map", not "unknown". + Bool IsBufferDrawCleanByHandle(MG_Pipe::MGPipeHandle res, const GLESBufferResource* resource, + const MG_State::GLState::BufferObject* frontend); + GLESBufferResource* EnsureBufferResourceForHandle( + const SharedPtr& bufferObject, MG_Pipe::MGPipeHandle res); + + // P3a (D-D): "the GPU wrote through this resource", announced on the reverse channel + // instead of poked into the frontend object. ARCHITECTURE.md calls OnGpuWritten a + // NARROWING channel - the client builds its pending set conservatively at each + // draw/dispatch emission point and this callback only ever takes entries out of it - + // so in P3a, where the client's conservative set is exactly what the three + // MarkGpuWritten sites marked, the announced set is the whole resource and the + // observable behaviour is identical. P8/P9 narrow it; the channel is what they need. + // + // The legacy arm keeps calling BufferObject::MarkGpuWritten directly, and the pull + // build never sees this function at all (G1). + void MarkBufferGpuWritten(const SharedPtr& bufferObject); + + // The applier's stored extent for this resource, 0 when it has no record. The one + // thing outside BufferImpl that needs it is the fp64 narrowing, whose source extent + // used to be BufferObject::GetSize(). + SizeT ResourceWidthForHandle(MG_Pipe::MGPipeHandle res); + // The applier's server-owned mutation serial for this resource, 0 when it has no + // record. It is what the narrowed-fp64 memo keys its freshness on now that the + // frontend change serial is gone from the backend's view. + Uint64 ResourceSerialForHandle(MG_Pipe::MGPipeHandle res); + // The ES context generation a twin's driver id must carry to be current. Its one + // consumer is the draw-clean probe's unit test, which has to build a twin that answers + // CLEAN to every question except the one under test - a case that cannot go red for + // that question otherwise. Push-only, like the rest of this block. + Uint CurrentBufferContextGeneration(); +#endif + + // P5e (vi), CONTRACT-P5E §5.1 + §5.8 (ruling 1 / ID-81): THE ARM SELECTOR for this + // family, and it is a conjunction on purpose. + // + // Transport != Monolith the record arm exists because there is no frontend VAO on + // this side of a real split. Under Transport=monolith the + // push build keeps its frontend arms token for token - that + // is what the verify comparator compares against, and what + // makes MOBILEGL_IPC_RUN_AHEAD=0 a pure wait-rule A/B on + // identical server code rather than an arm swap. + // the family bit `0x0ff` (bit 7 on, bit 8 off) is a supported A/B and must + // keep running the legacy vertex-input walk; the bit is + // already the gate the rest of this family reads. + // + // It is NOT gated on run-ahead. The records carry the whole family either way, so a + // lockstep split session reads them too and the wait rule changes nothing here - which + // is the only reason ra can flip one constant at the end of the phase and change no + // backend code at all. + // + // P5e (mv): it lives in the HEADER rather than in DirectGLES.cpp because the multi-draw + // path is a second translation unit that has to select the SAME arm - and the addendum's + // rule is that a site states the transport test it relies on, which a copy of the + // conjunction in MultiDraw.cpp would satisfy in letter while giving the family two + // selectors that can drift apart. One definition, spelled at every site that reads it. + inline Bool VertexInputReadsRecords() { +#if MOBILEGL_BUILD_DISAGGREGATED + return MG_Config::Transport != MG_Config::TransportMode::Monolith && + VertexInputSubsystemEnabled(); +#else + return false; +#endif + } + // Registered as the frontend's BufferBackendOps at backend init and on // every MakeCurrent (the ES context can be destroyed and recreated, e.g. // by the trace replayer's probe context). @@ -677,6 +1308,16 @@ namespace MobileGL::MG_Backend::DirectGLES { BackendVertexArrayObject(); ~BackendVertexArrayObject(); void SyncToBackend(const SharedPtr& stateVAOObject); +#if MOBILEGL_PIPE_PUSH + // PUBLIC AS OF P5e (vi), and the move is the point rather than a convenience: with + // a live transport there is no frontend VAO on this side to hand to the overload + // above, so VertexArrayImpl::SyncCurrentVAOFromRecords calls this ENTRY directly + // instead of passing a null SharedPtr into a function whose signature promises one. + // CONTRACT-P5E §4.1's rule ("the object parameter is deleted from the transport + // overload, not defaulted to null") is what this obeys. The body is unchanged and + // was already record-only - it is this family's existence proof (scout S1 §2). + void SyncToBackendFromApplier(); +#endif void SyncClientSideAttributesForDrawArrays( const SharedPtr& stateVAOObject, GLint first, GLsizei count); Uint GetBackendVertexArrayId() const { return m_backendVAOId; } @@ -696,9 +1337,46 @@ namespace MobileGL::MG_Backend::DirectGLES { // and is not covered by the config version). struct ResolvedDrawBuffers { struct Entry { + // MONOLITH GLUE AFTER P5e (vi), CONTRACT-P5E §5.1: the RECORD arm + // (SyncVaoAttributeBuffersByRecord) never writes this and never reads it - + // it stores nullptr and hands nullptr to IsBufferDrawCleanByHandle, whose + // frontend question is itself monolith-only (Managers.cpp's + // askTheObjectWhetherItIsMapped). It stays DECLARED because the legacy arm + // and the push-monolith arm still key on it and because moving it would + // move sizeof(ResolvedDrawBuffers) in the pull build (G1). That also + // retires, on the split arm, the dangling-pointer hazard the note below + // has to state. MG_State::GLState::BufferObject* frontend = nullptr; + // A RAW TWIN POINTER, AND IT MAY DANGLE - the invariant that makes that safe + // is stated here rather than left in the two callers (espryt-v3 §8, m8). + // + // Nothing tells this memo when a twin dies: on the handle arm a + // resource_destroy takes the twin out of the slot table (ReleaseByHandle) + // while this entry still holds its address, and on the legacy arm the same + // is true of the registry's own release. So the rule is: THIS POINTER IS + // ONLY EVER DEREFERENCED AFTER THE ENTRY'S IDENTITY HAS BEEN RE-RESOLVED IN + // THE SAME PASS - FindByHandle(handle) on the handle arm, the frontend + // identity compare on the legacy one - and a miss re-resolves through + // EnsureBufferResource rather than trusting what is stored here. Both + // consumers do that today; a third one that read `resource` straight out of + // a "valid" memo would be reading freed memory, and no compare in this + // struct would catch it. The pointer stays raw because the alternative - + // owning a reference from a per-draw memo - is what keeps a dead driver + // buffer alive, which is the leak class P2's death notice exists to remove. BufferImpl::GLESBufferResource* resource = nullptr; + // P5e (vi): on the record arm this is the BINDING index the applier's + // vertex-buffer entry carried (MGPVertexBuffer::BindingIndex, which for + // Espryt's resolved attributes IS the attribute index) and it is kept for + // DIAGNOSTICS only - that arm's repair re-ensures by `handle`, never by + // walking back into a frontend attribute slot. Uint8 attribIndex = 0; +#if MOBILEGL_PIPE_PUSH + // P3a re-key: the entry's identity on the handle arm. A {slot, gen} cannot + // be reproduced by a recycled heap address, so the clean probe compares + // this instead of the raw frontend pointer and never has to ask the + // allocator for it again mid-draw. + MG_Pipe::MGPipeHandle handle = MG_Pipe::kMGPipeNullHandle; +#endif }; Bool valid = false; Uint32 configVersion = 0; @@ -706,6 +1384,30 @@ namespace MobileGL::MG_Backend::DirectGLES { Array entries; MG_State::GLState::BufferObject* iboFrontend = nullptr; BufferImpl::GLESBufferResource* iboResource = nullptr; +#if MOBILEGL_PIPE_PUSH + // P3a re-key of the memo's validity key: on the handle arm the frontend VAO's + // wrapping configuration version is replaced by the bound vertex-elements CSO + // (identity AND its server-owned content serial) plus the vertex-buffer set's + // own serial - three monotone Uint64s and a {slot, gen}, no wrap and no + // identity patch. The IBO entry keeps its separate key for the same reason it + // always had one: the index slot is not part of the configuration (D5). + MG_Pipe::MGPipeHandle elementsHandle = MG_Pipe::kMGPipeNullHandle; + Uint64 elementsSerial = 0; + Uint64 buffersSerial = 0; + MG_Pipe::MGPipeHandle iboHandle = MG_Pipe::kMGPipeNullHandle; + // P5e (vi), CONTRACT-P5E §5.1: the index slot's own server-owned serial, and + // the reason it has to join the identity compare is the one D5 gives for the + // slot version - the ELEMENT BUFFER IS NOT PART OF THE CONFIGURATION. The + // legacy and push-monolith arms re-read the frontend slot on every indexed + // draw and so notice a rebind for free; the record arm reads nothing, so + // "the applier was told about this index buffer again" has to be a value in + // the key. A rebind of the SAME handle still moves IndexBufferSerial + // (MGPipeApplySetIndexBuffer bumps unconditionally), which is exactly the + // case an identity-only compare would call a hit: a client that respecified + // the store behind an unchanged {slot, gen} re-emits set_index_buffer and + // this is what re-opens the ensure. + Uint64 iboSerial = 0; +#endif // Buffer-mutation epoch (BufferImpl::CurrentBufferMutationEpoch) at which // the LAST probe pass found every entry / the IBO clean; 0 = not stamped // (epochs start at 1). While a stamp matches the pre-pass epoch read, the @@ -729,6 +1431,18 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 configVersion = 0; Uint32 activeMask = 0; Uint32 pendingMask = 0; +#if MOBILEGL_PIPE_PUSH + // P5e (vi), CONTRACT-P5E §5.1: the record arm's half of the key. It replaces + // the frontend VAO's configuration version with the bound vertex-elements CSO's + // identity and its server-owned content serial, and the substitution is + // STRICTLY STRONGER rather than merely equivalent: GetConfigVersion() is a + // wrapping Uint16-derived counter shared by every VAO, while every + // configuration change re-creates the record on the same handle and ++s + // ContentSerial (PipeApply.h's MGPipeVertexElementsRecord), and a DIFFERENT VAO + // is a different {slot, gen} rather than another value of the same counter. + MG_Pipe::MGPipeHandle elementsHandle = MG_Pipe::kMGPipeNullHandle; + Uint64 elementsSerial = 0; +#endif }; PendingAttribValueMask& GetPendingAttribValueMaskMemo() { return m_pendingAttribValueMask; } @@ -741,15 +1455,48 @@ namespace MobileGL::MG_Backend::DirectGLES { // dropping it. Returns false when the stream cannot be built, in which case the // caller must DISABLE the array - leaving a 64-bit array enabled with no pointer is // what the Adreno driver turns into a SIGSEGV at the next draw. +#if MOBILEGL_PIPE_LEGACY_MEMOS Bool SyncFloat64AttributeAsFloat32(Uint attribIndex, const MG_State::GLState::VertexAttribute& attrib, Uint32 fetchBaseInstance); +#endif + +#if MOBILEGL_PIPE_PUSH + // The handle arm of the whole vertex-elements half. Everything it needs arrives in + // the applier's records - the bound CSO's two views, the vertex-buffer set, the + // index buffer and the resolved fetch base instance - so it takes no argument at + // all and touches no frontend type. The legacy arm above it is unchanged and both + // compile in every push build (ARCHITECTURE.md 9.6). DECLARED IN THE PUBLIC SECTION + // as of P5e (vi) - see the note there. + // Same narrowing, same memo, same Adreno disable; the source bytes are the shadow + // base the resource call carried and the memo key is the buffer's {slot, gen}. + Bool SyncFloat64AttributeAsFloat32ByHandle(Uint attribIndex, const MGPVertexAttribWire& attrib, + const MG_Pipe::MGPVertexBuffer& binding, + Uint32 fetchBaseInstance); +#endif // What the converted float32 stream in m_convertedAttributeBufferIds[i] was built // from. A hit skips the CPU conversion and the re-upload; the buffer's change serial // is part of the key, so a glBufferSubData into the source invalidates it. struct ConvertedFloat64Stream { Bool valid = false; +#if MOBILEGL_PIPE_LEGACY_MEMOS + // The pre-handle pin: a FRONTEND lifetime id, i.e. the key + // ARCHITECTURE.md 9.5 lists for deletion as "ConvertedVertexStreamKey's + // sourcePin". Kept compiled for the legacy arm (and therefore present in + // every pull build, which is what keeps sizeof(this) still). Uint64 sourceLifetimeId = 0; +#endif +#if MOBILEGL_PIPE_PUSH + // What replaces it: the source buffer's {slot, gen}. It is the SAME identity + // the rest of the backend now keys on, it cannot be reproduced by a recycled + // frontend address, and it costs the walk no allocator probe - the handle is + // already in the vertex-buffer entry that named the source. + MG_Pipe::MGPipeHandle sourceHandle = MG_Pipe::kMGPipeNullHandle; +#endif + // On the handle arm this is the applier's server-owned Serial rather than the + // frontend change serial; both answer the same question - "have the source + // bytes moved since the conversion" - and neither is trusted for a + // persistently mapped buffer, which is written with no call at all. Uint64 sourceChangeSerial = 0; SizeT sourceOffset = 0; SizeT sourceStride = 0; @@ -772,6 +1519,12 @@ namespace MobileGL::MG_Backend::DirectGLES { // version early-out in SyncToBackend must not be trusted while it is set. Bool m_hasConvertedFloat64Attribute = false; Bool m_isInitialized = false; +#if MOBILEGL_PIPE_LEGACY_MEMOS + // ---- the pre-handle memo set (ARCHITECTURE.md 9.6) ------------------------- + // Retired by P3a on the handle arm and kept compiled here so the A/B is real: a + // cleared subsystem bit runs THESE, not a re-keyed twin wearing their names. A + // pull build forces MOBILEGL_PIPE_LEGACY_MEMOS ON, so sizeof(this) does not move + // and no symbol resizes (G1). Uint16 m_syncedIndexBufferVersion = 0; // Identity of the buffer the version above was stamped against. Raw and never // dereferenced: the slot version is a wrapping Uint16 (see the ResolvedDrawBuffers @@ -787,6 +1540,28 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint32 m_syncedConfigVersion = 0; Array m_syncedAttributeVersions; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS +#if MOBILEGL_PIPE_PUSH + // ---- what replaces them on the handle arm (D-G4) --------------------------- + // The bound vertex-elements CSO this twin last emitted, and the applier's + // server-owned content serial for it. Together they replace + // m_hasSyncedConfigVersion + m_syncedConfigVersion AND the whole per-attribute + // version array: the applier's stored Attributes[] IS what was last pushed, so a + // per-attribute compare has nothing left to prove and the walk re-emits. + MG_Pipe::MGPipeHandle m_syncedElementsHandle = MG_Pipe::kMGPipeNullHandle; + Uint64 m_syncedElementsSerial = 0; + Bool m_hasSyncedElements = false; + // The vertex-buffer set's own serial. Not in D-G4's table, and it has to be here: + // set_vertex_buffers is an independent call carrying the buffer identities, the + // offsets and the divisors this twin BAKES into the driver VAO, so a set that + // moved while the format did not must still re-emit them. + Uint64 m_syncedVertexBuffersSerial = 0; + // Replaces m_syncedIndexBufferVersion (a wrapping Uint16) AND + // m_syncedIndexBufferObject (the raw identity patch that closed its wrap hole): + // one monotone Uint64, no wrap, nothing to patch. This is the Track H re-key + // ARCHITECTURE.md 9.5 counts. + Uint64 m_syncedIndexSerial = 0; +#endif // Byte shift currently baked into the instanced arrays' offsets by the baseInstance // emulation (see SetPendingFetchBaseInstance). It is draw state, not VAO state, so it // is deliberately NOT covered by the config version: the frontend never bumps for it. @@ -800,9 +1575,27 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 m_syncedBufferIdGeneration = 0; }; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendVertexArrayObjects; +#if MOBILEGL_PIPE_PUSH + // P5e (id), CONTRACT-P5E §4.1 / §4.2: THE VAO TWIN, RESOLVED BY THE HANDLE THE RECORD + // CARRIED - `MGPipeApplierState::BoundVertexElements` at a draw, never + // `Find(vao.get())`. One of the three resolvers the identity package lands so the + // per-family packages have a by-handle door from day one; SamplerImpl's + // ResolveSamplerCsoTwin is the shape all four share. + // + // WHAT IT DOES AND, AS IMPORTANTLY, WHAT IT DOES NOT. Record first (a handle with no + // applier record is a seam defect, and adopting a slot for it would leave a twin that + // syncs nothing), then AdoptTwinByHandle, then a twin if the slot is empty. It never + // touches a frontend object and never probes the client's slot allocator - those two + // absences ARE the deliverable - and it does not SYNC: which serials gate a VAO sync, + // and what the sync reads, is the vi package's, and a resolver that synced would have + // to know. Null, loudly, for a handle with no record or a generation behind the live + // twin's; null silently for the null handle. + BackendVertexArrayObject* ResolveVaoTwin(MG_Pipe::MGPipeHandle elements); +#endif + // Shadowed glBindVertexArray: every backend VAO bind goes through here so a // draw's second bind of the same VAO (SyncToBackend, then PrepareForDraw's // re-bind) reaches the driver once. Invalidate whenever the ES context is @@ -816,6 +1609,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // instanced array at element "floor(instance / divisor) + baseInstance", and ES has no // way to say the "+ baseInstance" part - so it is folded into the attribute's own byte // offset (baseInstance * stride) for every divisor'd array, which is exactly equivalent. + // + // P3a RETIRES THE AMBIENT GLOBAL (D-H2): an ambient process global cannot cross a + // pushed boundary, so on the handle arm the draw's RAW base instance rides in + // MGPVertexBuffers::BaseInstance and the SERVER decides whether to shift - the answer + // lands in MGPipeApplierState::VertexFetchBaseInstance and the VAO sync reads it there. + // The three declarations below and the three scopes in DirectGLES.cpp are the legacy + // arm's, kept compiled because a cleared subsystem bit has to run a real pre-handle + // path and because removing them would delete two symbols from the PULL build (G1). +#if MOBILEGL_PIPE_LEGACY_MEMOS // Must be set BEFORE PrepareForDraw so the VAO sync sees it, and cleared after the draw // so the next one refetches from element 0; ScopedFetchBaseInstance does both. void SetPendingFetchBaseInstance(Uint32 baseInstance); @@ -828,6 +1630,32 @@ namespace MobileGL::MG_Backend::DirectGLES { ScopedFetchBaseInstance(const ScopedFetchBaseInstance&) = delete; ScopedFetchBaseInstance& operator=(const ScopedFetchBaseInstance&) = delete; }; +#endif + +#if MOBILEGL_PIPE_PUSH + // The server-owned half of the same decision, and the reason the client never + // pre-shifts an offset: emulation ownership is the server's (ARCHITECTURE.md 5.7). + // True when the driver applies baseInstance to the vertex fetch itself, in which case + // the attribute-offset emulation must stay out of the way. Applied to whatever the + // applier stored, so the answer is the same whichever side resolved it first. + Bool BackendUsesNativeBaseInstance(); + + // ---- P5e SEAM (MG_Remote/CONTRACT-P5E.md §4.2; declared by c0e, bodied by id/vi) ---- + // + // THE VAO TWIN, RESOLVED FROM THE HANDLE THE RECORD CARRIED - MGPipeApplierState:: + // BoundVertexElements - instead of from the frontend VertexArrayObject the draw's + // BARRIER_PULLED row hands over. The frontend overload above it stays as the + // monolith-glue half, in the shape ResolveSamplerCsoTwin already established for the + // sampler CSO family: two overloads, not an #if inside one body, so which arm a caller + // is on is visible at the call site. + // + // It is declared HERE, with a body that aborts by name, because the packages that fill + // it in land in parallel worktrees: id rekeys the registry under it, vi moves + // PrepareForDraw's call onto it, and neither may edit the other's file. A missing + // declaration would make that a merge conflict; a declaration with a quiet body would + // make it a null twin and a blank draw. + BackendVertexArrayObject* ResolveVaoTwin(MG_Pipe::MGPipeHandle vertexElements); +#endif } // namespace VertexArrayImpl namespace TextureImpl { @@ -951,6 +1779,14 @@ namespace MobileGL::MG_Backend::DirectGLES { BackendTextureObject(const BackendTextureObject&) = delete; BackendTextureObject& operator=(const BackendTextureObject&) = delete; void SyncMipmapsToBackend(const SharedPtr& stateTextureObject); +#if MOBILEGL_PIPE_PUSH + // P5e SEAM (declared by c0e, bodied by tx2): the same storage sync keyed on the + // texture HANDLE, reading the applier's resource record and the server's staged + // store instead of the frontend object's levels and pending uploads. fb's + // attachment sync and the image sweep both call it, which is why it is declared + // once here rather than twice in two packages' worktrees. + void SyncMipmapsToBackendByHandle(MG_Pipe::MGPipeHandle texture); +#endif // The storage half of the sync for a texture created by glTextureView. Instead of // allocating storage and replaying uploads, it makes this object's ES name BE a view // of the storage texture's ES name (EXT/OES_texture_view), which is what gives the @@ -958,6 +1794,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // parameter and sampler halves are unchanged and run on this name as on any other. void SyncTextureViewToBackend(const SharedPtr& stateTextureObject); void StampViewSyncKeys(const SharedPtr& stateTextureObject); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (tx2). THE VIEW ARM UNDER A TRANSPORT, and what it can and cannot answer. + // + // `Desc.ViewOf` names the storage owner BY HANDLE, so the STEADY question - "is my ES + // name still a view of the same storage name" - is answered entirely server-side: sync + // the storage twin by handle, compare its id against m_viewSourceBackendTextureId, + // stamp the record's Serial. That is the per-draw cost of every already-synced view + // and it reads nothing from the client. + // + // The CREATION of the view is the gap and it is named rather than papered over: the + // glTextureView call needs (minLevel, numLevels, minLayer, numLayers) and MGPResourceDesc + // carries none of the four - `ViewOf` is the only view field on the wire. So a view whose + // ES name has not been made a view YET, or whose storage was re-minted underneath it, + // still needs the frontend object and takes it when one is reachable; with no object it + // declines LOUDLY and the view samples its own (empty) name. Listed as trailing in the + // tx2 report with the wire fields it needs. + void SyncTextureViewToBackendByRecord( + MG_Pipe::MGPipeHandle res, const MG_Pipe::MGPipeResourceRecord& record, + const SharedPtr& stateTextureObject); +#endif // The storage half of the sync for a texture created by glTextureView. Instead of // allocating storage and replaying uploads, it makes this object's ES name BE a view // of the storage texture's ES name (EXT/OES_texture_view), which is what gives the @@ -971,6 +1827,24 @@ namespace MobileGL::MG_Backend::DirectGLES { // re-mint allocates fresh storage and only replays what the shadow still calls dirty. void RequireImageBindableStorage( const SharedPtr& stateTextureObject); +#if MOBILEGL_PIPE_PUSH + // P5e (tx2), CONTRACT-P5E §5.2 / G-S2-5. THE SAME TRANSITION WITH NO FRONTEND + // ARGUMENT, and with the re-dirty NAMED AT THE ENTRY instead of three frames deep. + // + // The frontend overload's whole second half is the re-dirty: it walks the CLIENT's + // level shadows to re-arm every level the widened carrier owes. A server has no + // client address space to walk, so under a transport that half is not "not migrated + // yet", it is not expressible - ImageBindableHint is the prevention (a texture that + // has ever been image-bound is allocated in the carrier from the start) and P9 owns + // the pull. The refusal is raised here, at the entry, where the reason can still be + // stated, rather than at the MarkStorageDirty guard the loop would reach. + // + // A texture arriving here with NO backend storage yet pulls nothing - it is simply + // allocated image-bindable up front - so that case is not refused, it is the whole + // point of the hint. + void RequireImageBindableStorageByHandle(MG_Pipe::MGPipeHandle res, + const MG_Pipe::MGPipeResourceRecord& record); +#endif // Whether this texture's ES storage was minted in an image carrier rather than in the // frontend format's own layout - the readback has to ask, because for a NORMALIZED // carrier the storage is an integer texture holding codes and glGetTexImage still owes @@ -1019,6 +1893,39 @@ namespace MobileGL::MG_Backend::DirectGLES { return t->GetStorageType() == TextureStorageType::Mipmap; } +#if MOBILEGL_PIPE_PUSH + // P5e (tx2), CONTRACT-P5E §5.2. THE SAME AGGREGATE GATE WITH NO FRONTEND OBJECT IN + // IT, and it is EXACTLY the conjunction of the three handle-arm prologues' own + // early-outs - the relation IsDrawSyncClean states above for the frontend versions, + // restated over the server's serials: + // + // SyncMipmapsToBackend m_isInitialized && m_syncedResourceSerial == Serial + // && PendingUploads.empty() + // SyncTextureParamsToBackend m_syncedParamsSerial == ParamsSerial + // && !Params.ForceResync && !m_forceTextureParamsResync + // SyncBuiltinSamplerToBackend m_syncedBuiltinSampler == Params.BuiltinSampler + // && m_syncedBuiltinSamplerSerial == that CSO's Serial + // && !Params.SamplerResync && !m_forceSamplerResync + // + // plus the storage-kind restriction the frontend gate carries, answered from + // Desc.StorageKind. No new member and no new wire field: every input already + // exists, which is the whole reason the memo-HIT path can stop being frontend-bound. + // + // BOTH SIDES OF THE RESYNC PAIR ARE READ AND NEITHER CLEARS THE OTHER'S (D-E2, now + // load-bearing for a GATE rather than only for a push, scout R4). `Params.ForceResync` + // and `Params.SamplerResync` are the CLIENT's bits: the server reads them, acts on + // them and never writes them back. `m_forceTextureParamsResync`/`m_forceSamplerResync` + // are the SERVER's: set by RecreateBackendTexture / RequireImageBindableStorage and + // cleared only by the prologue that consumed them. A gate that read one side would + // skip the sync the other side is asking for, which for the sampler half is not + // mis-filtering but an INCOMPLETE texture sampling (0,0,0,1). + // + // The contextId / samplingGeneration arguments are GONE: the applier's ContextSerial + // and TextureShutterSerial are what the caller's keys already carry. + Bool IsDrawSyncCleanByRecord(MG_Pipe::MGPipeHandle res, + const MG_Pipe::MGPipeResourceRecord& record) const; +#endif + private: void RecreateBackendTexture(); @@ -1117,15 +2024,134 @@ 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). + // + // P5e (tx2): the HANDLE comes from m_pushedSyncHandle when the caller adopted this + // twin by handle, and only then from the client allocator - which is what retires + // the two `HandleOf` probes this pair used to make on every feature path. The record + // is resolved once by ResolveOwnRecord below and passed to the sampler half rather + // than looked up twice. + const MG_Pipe::MGPipeResourceRecord* ResolveOwnRecord( + const SharedPtr& stateTextureObject) const; + const SamplerParameters* ResolvePushedBuiltinSampler( + const SharedPtr& stateTextureObject, + const MG_Pipe::MGPipeResourceRecord* record); + // 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& 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; + + // P5e (tx2), CONTRACT-P5E §4.1's `m_handle` (the m_pushedSyncHandle pattern the + // framebuffer twin already carries). THE HANDLE THIS TWIN WAS ADOPTED FOR. + // + // It is what replaces `g_backendTextureObjects.HandleOf(stateTextureObject.get())` + // in the three sync prologues: the by-handle entry point knows the handle before it + // knows anything else, so the prologues stop probing the client allocator to + // re-derive an answer the caller already had. It is ALSO the arm selector - a twin + // with a handle noted syncs from the record and tolerates a null frontend object, + // one without it is the monolith-glue half and reads the object as it always did. + // + // Stamped by SyncTextureToBackendByHandle and SyncMipmapsToBackendByHandle - the two + // entries that HAVE a handle - and never cleared afterwards: + // a slot recycled to {s, g+1} RESETS the twin (SlotTables.h's forward-Gen rule), so + // a stale handle cannot outlive the object it names. + MG_Pipe::MGPipeHandle m_pushedSyncHandle = MG_Pipe::kMGPipeNullHandle; + + public: + void NotePushedSyncHandle(MG_Pipe::MGPipeHandle res) { m_pushedSyncHandle = res; } + MG_Pipe::MGPipeHandle PushedSyncHandle() const { return m_pushedSyncHandle; } + + private: +#endif }; void ActivateTextureUnit(Uint unit); void UnbindTexture(Uint unit, GLenum target); - extern StateBackendObjectRegistry + extern TwinRegistry g_backendTextureObjects; + +#if MOBILEGL_PIPE_PUSH + // P5e (id), CONTRACT-P5E §4.1 / §4.2: THE TEXTURE TWIN BY HANDLE - the handle a record + // carried (`BoundSamplerViews[u].Texture`, `BoundShaderImages[u].Res`, a framebuffer + // record's `MGPSurface::Res`, `VerbMipRes`, `VerbCopyTexDst`, `MGPCopyImage`'s two + // endpoints), never `Find(textureObject.get())`. Same shape and the same three + // absences as VertexArrayImpl::ResolveVaoTwin: record first, no frontend touch, no + // allocator probe, and NO SYNC - the three texture syncs and the clean condition that + // gates them are the tx2 package's. + // + // The by-value twin copy plus second Find that SyncTextureObjectToBackend pays today + // (the registry's Find could relocate its own return) is not reproduced here: the slot + // table's answer is an array element and only a GetOrCreate that GROWS the table moves + // it, which this function has already done by the time it returns. + BackendTextureObject* ResolveTextureTwin(MG_Pipe::MGPipeHandle res); +#endif SharedPtr& SyncTextureObjectToBackend( const SharedPtr& textureObject, Bool imageBindableStorageRequired = false); +#if MOBILEGL_PIPE_PUSH + // ---- P5e SEAM (MG_Remote/CONTRACT-P5E.md §4.2, §5.2; declared by c0e, bodied by + // id/tx2) -------------------------------------------------------------------------- + // + // EVERY DRAW-PATH TEXTURE ENTRY, BY HANDLE. The caller holds a Texture handle - a + // sampler view's Texture, an image unit's Res, a framebuffer surface's Res, VerbMipRes, + // VerbCopyTexDst, a copy-image endpoint - and the sync resolves the RECORD first and the + // twin by GetOrCreateByHandle, so nothing on the apply thread dereferences a frontend + // ITextureObject. The frontend overload above stays as the monolith-glue half. + // + // ResolveTextureTwin is the lookup without the sync, for the callers that need the twin + // to answer a shape question (an image bind's target and storage format come off the + // twin, not off a new wire field) after tx2's sync has already run this frame. + SharedPtr& SyncTextureToBackendByHandle( + MG_Pipe::MGPipeHandle texture, Bool imageBindableStorageRequired = false); + BackendTextureObject* ResolveTextureTwin(MG_Pipe::MGPipeHandle texture); +#endif // Brings every texture the next draw reads - the touched units' bindings and the draw // FBO's texture attachments - onto the backend, through the two borrowed-pair memos // documented at their definitions. Declared here so tests can drive those memos directly. @@ -1150,10 +2176,34 @@ namespace MobileGL::MG_Backend::DirectGLES { BackendFramebufferObject& operator=(const BackendFramebufferObject&) = delete; void SyncToBackend(const SharedPtr& stateFBOObject, FramebufferTarget asTarget); +#if MOBILEGL_PIPE_PUSH + // P5e SEAM (MG_Remote/CONTRACT-P5E.md §5.4; declared by c0e, bodied by fb): the same + // sync keyed on the framebuffer HANDLE. The record's eleven surfaces ARE the point + // set - the emitter refuses a point at or above the wire width - so the attachment + // walk needs no frontend FramebufferObject and no m_pushedSyncHandle handshake: the + // handle is the argument. An OVERLOAD rather than a changed signature, so the + // monolith arm and the pull build see no token move. + void SyncToBackendByHandle(MG_Pipe::MGPipeHandle fbo, FramebufferTarget asTarget); +#endif // Apply only this FBO's read buffer (glReadBuffer) to the backend. Split out so it can // still run when SyncCurrentFBO skips the READ-target sync because the same GL FBO is // bound as both draw and read (otherwise glReadBuffer changes would be silently dropped). void SyncReadBufferToBackend(const SharedPtr& stateFBOObject); +#if MOBILEGL_PIPE_PUSH + // P5e (fb, §5.4): the same read-buffer push keyed on the handle, for the one path + // that applies a read buffer without doing the rest of the sync - SyncCurrentFBO's + // "one object is bound to BOTH bindings" skip, where the DRAW pass already did the + // attachment work and only glReadBuffer is READ-target-specific. + void SyncReadBufferToBackendByHandle(MG_Pipe::MGPipeHandle fbo); +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.2): the framebuffer handle the CURRENT sync is keyed on. + // A caller applying a record sets it before SyncToBackend / SyncReadBufferToBackend, + // which then read the applier's record for THAT handle instead of probing the + // client's slot allocator for the frontend object's lifetime id (T2). Read only + // with an active transport; monolith resolves through HandleOf as it always did. + MG_Pipe::MGPipeHandle m_pushedSyncHandle = MG_Pipe::kMGPipeNullHandle; +#endif void InvalidateSyncedState(); Uint GetBackendFramebufferId() const { return m_backendFBOId; } void Bind(FramebufferTarget target) const; @@ -1209,11 +2259,97 @@ 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 m_syncedRecordHashes = {0}; + // P5e (fb): the read-buffer decision, taken from the record alone. Both + // SyncReadBufferToBackend overloads funnel through it, so the rule lives in one + // place and the object form is visibly the half that only finds the handle. + // glNameForDiag is 0 on the handle arm, which reads as "the record did not say". + void ApplyReadBufferFromRecord(const MG_Pipe::MGPFramebufferState& record, Uint glNameForDiag); +#endif }; - extern StateBackendObjectRegistry + extern TwinRegistry 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); + + // ---- P5e (fb, CONTRACT-P5E.md §5.4): the reverse index, texture -> framebuffers ------ + // + // "Which framebuffers currently have this texture attached." The detach walk + // (ScopedDetachedTextureFramebufferAttachments) used to answer it by iterating every + // live twin and reading each one's FRONTEND attachment array, which is the last place + // the server held a frontend framebuffer across records - id re-typed ForEachLive and + // named this package as the one that replaces the read. The relation is maintained + // where the record is consumed (SyncToBackendByHandle's attachment walk), because the + // record's eleven surfaces already say it. + // + // The index answers about a TEXTURE SLOT and returns whole FRAMEBUFFER handles, so a + // recycled framebuffer slot never answers for its predecessor. See the definition for + // why nothing prunes a dead row. + void NoteFramebufferTextureAttachments(MG_Pipe::MGPipeHandle fbo, + const MG_Pipe::MGPFramebufferState& record); + Vector FramebuffersAttachingTexture(MG_Pipe::MGPipeHandle texture); + + // The record's surface for an attachment POINT, or null when this record does not + // describe that point at all (Color8..Color31, and the default framebuffer's FRONT/BACK + // tokens). MGPFramebufferState carries Color[8] + Depth + Stencil, which is every point + // a framebuffer can hold on the handle arm - D-C3 refuses bit 9 outright on a driver + // reporting more than 8 colour attachments. P5e (fb) exports it: the twin's attachment + // walk, the named blit's aspect plan and the detach walk all ask the same question of + // the same record, and a second copy of the Color/Depth/Stencil dispatch beside each of + // them is how one of them ends up describing a point differently from the others. + const MG_Pipe::MGPSurface* PushedSurfaceForAttachment(const MG_Pipe::MGPFramebufferState& record, + FramebufferAttachmentType point); +#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 @@ -1307,6 +2443,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. @@ -1321,6 +2467,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 @@ -1470,6 +2637,173 @@ namespace MobileGL::MG_Backend::DirectGLES { // Defined further down, next to CollectImageFormatBakeInputs; only referenced here. struct ImageFormatBakeInputs; +#if MOBILEGL_PIPE_PUSH + // ---- P5e (pg), CONTRACT-P5E.md §5.5: THE ONE SOURCE A PROGRAM BUILD READS ---------- + // + // WHY THIS TYPE EXISTS AT ALL. Building a driver program asks the frontend + // ProgramObject sixteen distinct questions and asks its reflection tables thousands of + // times, and EVERY ONE OF THEM used to be a read of client-owned memory on the apply + // thread. Under run-ahead that is not a race to be careful about, it is wrong by + // construction: ProgramObject::Link() REPLACES m_artifacts and m_spirv in place, and by + // the time the server builds, the client may be several links past the one the record + // describes. So the build reads THIS instead, and the two arms differ only in where it + // points. + // + // AND IT IS DELIBERATELY NOT A SECOND ProgramObject. Nothing here constructs a frontend + // object on the apply thread - ID-102's lesson, learned by id: a ProgramObject's own + // constructor mints a lifetime id, so an "object" built server-side would trip the very + // guards this phase installs and would make a red-once falsely green. + // + // THE OVERLAY IS THE OTHER HALF. Three reflection fields - the block-to-point map, the + // sampler unit per uniform LOCATION and the name-keyed storage-block override set - are + // members of LinkArtifacts that glUniformBlockBinding, glUniform1i and + // glShaderStorageBlockBinding move AFTER the link that produced the archive, without + // relinking. On the handle arm the record's set_program_bindings tails are the whole + // truth for all three (whole-set replacement, not a merge) and the archive's link-time + // values are shadowed; on the monolith arm there is no overlay because the "archive" + // IS the frontend's live table. + // + // THE ACCESSORS BELOW MIRROR ProgramObject's, one for one, and that duplication is + // deliberate rather than lazy: MG_Backend includes nothing from MG_State's program + // internals beyond the archive structs themselves, and ProgramObject's own forms are + // non-static members over Artifacts(). The three that ARE already static over + // LinkArtifacts (IsValidUniformLocation, UniformAtIn, GetUniformArraySizeByTIndex) are + // called rather than copied, which is where the boundary sits. + struct ProgramArchiveSource { + using LinkArtifacts = MG_State::GLState::LinkArtifacts; + using SpirvArtifacts = MG_State::GLState::SpirvArtifacts; + using UniformReflection = MG_State::GLState::ResourceReflection; + using TypeFacts = MG_State::GLState::TypeFacts; + using XfbVarying = MG_State::GLState::XfbVarying; + + // Both are non-null for the whole lifetime of a source; a source is a local of the + // build that made it and never outlives the record or the object it points into. + const LinkArtifacts* Link = nullptr; + const SpirvArtifacts* Spirv = nullptr; + + // WHAT A LOG LINE NAMES. The frontend's GL program name on the monolith arm; the + // ShaderCso slot on the handle arm, because the server does not know GL names and + // must not learn them (the handle is the identity the whole phase speaks in). + Uint Identity = 0; + // True when Identity is a handle slot rather than a GL name, so a message can say + // which it printed instead of leaving a reader to guess. + Bool IdentityIsHandleSlot = false; + + Bool Linked = false; + Bool SpirvUsable = false; + Bool SpirvValidationEnabled = false; + Bool PointSizeWasDemoted = false; + Uint GlobalUboSize = 0; + + // One entry per Spirv->generatedSpirv module, at the same index. Owned rather than + // referenced because the monolith arm builds it (GetLinkedShaderStages() returns by + // value) and the handle arm converts the frame's Uint32 words. + Vector LinkedStages; + + // The post-link overlay. Governs iff OverlayGoverns; see the type comment. + Bool OverlayGoverns = false; + const Vector* BlockBindingOverlay = nullptr; + const Vector* SamplerUnitOverlay = nullptr; + // Built once per build from whichever side owns it, because TranspileSpirvToEssl + // takes the map by const reference and the record carries a vector. + UnorderedMap StorageOverrides; + // The client's commutative hash on the handle arm, ComputeShaderStorageBlockBinding- + // Signature's on the monolith one. Same function, computed on whichever side owns + // the map (MG_Impl/Pipe/ProgramEmit.h names its twin). + Uint64 StorageOverrideSignature = 0; + + // ---- the archive's own answers ---- + // + // EVERY NAME HERE IS ProgramObject's NAME, and that is load-bearing rather than + // tidy: the build body below is written once against `src`, whose TYPE is the + // build's (ProgramBuildSource), so the pull build compiles the very same text + // against a ProgramObject and G1's byte identity survives. + Uint GetExternalIndex() const { return Identity; } + Bool GetLinkStatus() const { return Linked; } + Bool GetSpirvStatus() const { return SpirvUsable; } + Bool GetSpirvValidationEnabled() const { return SpirvValidationEnabled; } + Bool PointSizeDemoted() const { return PointSizeWasDemoted; } + Uint GetUBOSize() const { return GlobalUboSize; } + const Vector& GetLinkedShaderStages() const { return LinkedStages; } + const UnorderedMap& GetShaderStorageBlockBindingOverrides() const { + return StorageOverrides; + } + // THE DEBUG LOG IS DELETED ON THIS ARM (CONTRACT-P5E §5.5). The snapshot is + // GL-thread-owned ShaderObject SharedPtrs - the exact shape rule C forbids an + // applier entry point to reach - and its one consumer in the build is an MGLOG_D + // dump of each stage's original GLSL, which the server does not have and does not + // need. An empty list makes that loop a no-op rather than a special case. + const Vector& GetLinkedShaderSnapshot() const { + static const Vector kNone; + return kNone; + } + + Uint GetMaxUniformLocation() const { return Link->maxUniformLocation; } + Bool IsValidUniformLocation(Int location) const { + return MG_State::GLState::ProgramObject::IsValidUniformLocation(*Link, location); + } + const UniformReflection& UniformAt(Int tIndex) const { + return MG_State::GLState::ProgramObject::UniformAtIn(*Link, tIndex); + } + // Bounds-checked, exactly as ProgramObject::GetUniformName is not: the frontend's + // form indexes uniformIndexInTProgram raw because every caller there has already + // walked a legal location, and this one is reached from a handle arm where the + // location space comes off a record. + const String& GetUniformName(Uint location) const { + static const String kEmpty; + if (location >= Link->uniformIndexInTProgram.size()) return kEmpty; + return UniformAt(Link->uniformIndexInTProgram[location]).name; + } + GLenum GetUniformType(Uint location) const { + if (location >= Link->uniformIndexInTProgram.size()) return 0; + return UniformAt(Link->uniformIndexInTProgram[location]).glDefineType; + } + const TypeFacts& GetUniformTypeFacts(Uint location) const { + static const TypeFacts kEmpty{}; + if (location >= Link->uniformIndexInTProgram.size()) return kEmpty; + return UniformAt(Link->uniformIndexInTProgram[location]).type; + } + Bool UniformLocationsAliasSameUniform(Int a, Int b) const { + if (!IsValidUniformLocation(a) || !IsValidUniformLocation(b)) return false; + return Link->uniformIndexInTProgram[a] == Link->uniformIndexInTProgram[b]; + } + // ProgramObject::GetUniformLocation, reproduced over the archive. The array rules + // are the whole body: reflection keys an array under "arr[0]" at its base location, + // a bare "arr" resolves to that, an "arr[k]" resolves to base + k, and an array of + // arrays is keyed by its full "[0]"-terminated spelling - which is why the + // suffixed lookup is tried before the trailing subscript is read as an index. + Int GetUniformLocation(const String& name) const; + Int GetActiveUniformBlocksCount() const { + return static_cast(Link->glBlockIndexToTProgram.size()); + } + const String& GetUniformBlockName(Uint index) const; + + GLenum GetTransformFeedbackBufferMode() const { return Link->xfbBufferMode; } + SizeT GetTransformFeedbackVaryingCount() const { return Link->xfbVaryings.size(); } + const Vector& GetTransformFeedbackVaryings() const { return Link->xfbVaryings; } + const Vector>& GetGeneratedSpirv() const { return Spirv->generatedSpirv; } + + // ---- the three the overlay governs ---- + Uint GetUniformBlockBinding(Uint index) const; + Int GetUniformSamplerOrImageUnitIndex(Uint location) const; + + // The monolith-glue constructor: the archive IS the frontend's live tables, so + // there is no overlay and the three mutable fields answer from them directly. + static ProgramArchiveSource FromFrontend(const MG_State::GLState::ProgramObject& program); + // The handle arm: the record's own archive, with its three tails overlaid. + static ProgramArchiveSource FromRecord(MG_Pipe::MGPipeHandle cso, + const MG_Pipe::MGPipeShaderCsoRecord& record); + }; + + // THE BUILD'S SOURCE TYPE, per build. This alias is what lets the program build have one + // body: in a push build it is the record-or-frontend view above, in a pull build it is + // the frontend object the body always read, spelled through the same name so the text + // does not move (G1). + using ProgramBuildSource = ProgramArchiveSource; +#else + using ProgramBuildSource = MG_State::GLState::ProgramObject; +#endif + class BackendProgramObjectImpl { public: // Per-link cache of a sampler-style uniform's backend location: built once in @@ -1529,6 +2863,14 @@ namespace MobileGL::MG_Backend::DirectGLES { BackendProgramObjectImpl(); ~BackendProgramObjectImpl(); void SyncToBackend(const SharedPtr& stateProgramObject); +#if MOBILEGL_PIPE_PUSH + // P5e (pg), CONTRACT-P5E.md §5.5: the same sync keyed on the ShaderCso HANDLE and + // answered from the record - the archive the create carries and the three binding + // tails set_program_bindings carries. It is an OVERLOAD beside the frontend one, + // which stays as the monolith-glue half, so the pull build's mangled names do not + // move (ruling 1 / ID-81: two overloads, not an #if inside one body). + void SyncToBackendByHandle(MG_Pipe::MGPipeHandle cso); +#endif void Use(); void SetBaseInstance(Uint32 baseInstance) const; void SetBaseInstanceWordIndex(Int32 wordIndex) const; @@ -1612,6 +2954,29 @@ 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; } + // P5e (pg): the SECOND server-owned key, and it replaces GetImageUnitVersion() on + // the handle arm rather than duplicating the one above. A glUniform1i on an IMAGE + // uniform is BAKED into the generated ESSL (ES forbids the call outright), so the + // record that carries the new unit has to force a rebuild; BindingsSerial moves on + // every applied set_program_bindings, which is exactly when one can have changed. + // 0 means "never stamped", a guaranteed miss, because applier serials start at 1. + Uint64 GetSyncedBindingsSerial() const { return m_syncedBindingsSerial; } +#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. // @@ -1636,7 +3001,12 @@ namespace MobileGL::MG_Backend::DirectGLES { Uint64 ComputeImageUnitFormatSignature() const; private: - void CacheResourceLocations(const SharedPtr& stateProgramObject); +#if MOBILEGL_PIPE_PUSH + // The one body both public heads feed; see its definition for why it is a worker in + // a push build and IS SyncToBackend in a pull build. + void SyncToBackendFromSource(const ProgramBuildSource& src); +#endif + void CacheResourceLocations(const ProgramBuildSource& src); // Builds, compiles and attaches the pass-through tessellation control stage GL 4.6 // core 11.2.2 describes, for a program that has an evaluation stage and none of its @@ -1644,7 +3014,7 @@ namespace MobileGL::MG_Backend::DirectGLES { // stage has been attached and before the link; see the definition for why it cannot // regress a program that works today. void AttachPassthroughTessControlStage( - const MG_State::GLState::ProgramObject& stateProgramObject, Int tessEvalShaderIndex, + const ProgramBuildSource& src, Int tessEvalShaderIndex, const Vector>& shaderSpirvs, const String& vertexStageEssl, const String& tessEvalStageEssl); @@ -1711,6 +3081,12 @@ 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; + Uint64 m_syncedBindingsSerial = 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 - @@ -1730,9 +3106,26 @@ namespace MobileGL::MG_Backend::DirectGLES { // skip redundant rebinds. Reset to 0 wherever glUseProgram(0) is issued or the // ES context is recreated. extern Uint g_lastUsedBackendProgramId; - extern StateBackendObjectRegistry + extern TwinRegistry g_backendProgramObjects; +#if MOBILEGL_PIPE_PUSH + // P5e (id), CONTRACT-P5E §4.1 / §4.2: THE PROGRAM TWIN BY HANDLE - `st.DrawProgram` at + // a draw, `st.DispatchProgram` at a dispatch, `st.BoundShaderCso` at a bind - never + // `Find(currentProgram.get())` and never the raw-pointer stash. Same shape and the same + // three absences as the VAO and texture resolvers beside it: record first, no frontend + // touch, no allocator probe, no sync (the nine-clause clean condition and what feeds it + // are the pg package's). + // + // THE BAND IS WHY THIS ONE MATTERS MOST. A program-pipeline composite's ShaderCso slot + // is >= kMGPipeShaderCsoCompositeSlotBase, and before P5e the by-handle path would have + // grown g_backendProgramObjects to ~983k entries to reach it. SlotTables.h's m_band + // lands in this package for exactly that reason - the rekey makes the composite the + // ordinary path, so the band is a prerequisite and not a follow-up. The record reader + // (PipeShaderCsoRecordForHandle) has been band-aware since P4a. + BackendProgramObjectImpl* ResolveProgramTwin(MG_Pipe::MGPipeHandle cso); +#endif + // Points one shader storage block of an ALREADY-LINKED backend program at // `binding`. `blockName` is the frontend interface-query spelling; the real // driver's own index for it is looked up here, because the transpiled ESSL's @@ -1751,15 +3144,20 @@ namespace MobileGL::MG_Backend::DirectGLES { // program that was just built - best effort, on the same "only where the driver has // the entry point" terms as ApplyShaderStorageBlockBinding above. Mirrors // DirectVulkan's reseed-on-rebuild in BuildProgramResourceCache. - void ReseedShaderStorageBlockBindings(Uint backendProgramId, - const MG_State::GLState::ProgramObject& stateProgramObject); + void ReseedShaderStorageBlockBindings(Uint backendProgramId, const ProgramBuildSource& src); // Order-independent digest of the program's glShaderStorageBlockBinding overrides. // The generated ESSL carries them (ES has no way to move a storage block's binding // after link), so a program built against a different set is stale and the draw path // has to rebuild it. Computed from the values, so re-setting a block to the binding it // already has costs nothing. 0 when nothing was ever rebound. - Uint64 ComputeShaderStorageBlockBindingSignature( - const MG_State::GLState::ProgramObject& stateProgramObject); + Uint64 ComputeShaderStorageBlockBindingSignature(const ProgramBuildSource& src); +#if MOBILEGL_PIPE_PUSH + // P5e (pg): the computation itself, for the monolith arm - the source constructor seeds + // itself with it and the monolith draw path asks it per draw. Push-only: in a pull build + // the overload above IS this body, so no name is added there. + Uint64 ComputeShaderStorageBlockBindingSignatureOf( + const MG_State::GLState::ProgramObject& program); +#endif // Everything the image-format bake needs from one walk of a program's uniform // reflection. GLSL ES requires a format layout qualifier on every image uniform; @@ -1800,8 +3198,36 @@ namespace MobileGL::MG_Backend::DirectGLES { // keeps the module it already had. Bool declaresWidenableImageFormat = false; }; - ImageFormatBakeInputs CollectImageFormatBakeInputs( - const MG_State::GLState::ProgramObject& stateProgramObject); + ImageFormatBakeInputs CollectImageFormatBakeInputs(const ProgramBuildSource& src); + +#if MOBILEGL_PIPE_PUSH + // ---- P5e SEAM (MG_Remote/CONTRACT-P5E.md §4.2, §5.5; declared by c0e, bodied by + // id/pg) --------------------------------------------------------------------------- + // + // THE PROGRAM TWIN, RESOLVED FROM MGPipeApplierState::DrawProgram / DispatchProgram / + // BoundShaderCso instead of from GetProgramForDraw()'s frontend SharedPtr - the second + // unconditional pointer read of every draw, and one of the two rows whose retirement is + // what P5e is for. Composite pipeline programs resolve through the same call: their + // slots come out of the allocator's composite band, which the server's slot table gains + // a band for so the ordinary table does not grow to a million entries. + // + // Null, loudly, when the handle names no record or cannot be adopted - the shape + // ResolveSamplerCsoTwin set - and null silently for the null handle, which is the legal + // "nothing bound". + BackendProgramObjectImpl* ResolveProgramTwin(MG_Pipe::MGPipeHandle cso); + + // P5e (pg): the two PER-DRAW reads of set_program_bindings' tails, as free functions + // rather than through a ProgramArchiveSource - building a source per draw would copy the + // override map for a question that is one indexed read. Both answer exactly what their + // frontend twins do when the record carries no bindings yet: the archive's own link-time + // value, which for an untouched program IS the right answer. + // + // The block binding is dense in the BLOCK index space; the sampler unit is sparse and + // ascending by LOCATION, so it is a binary search (a program has thousands of locations + // and a handful of samplers, and a linear scan per sampler would be quadratic). + Uint ProgramBlockBindingFromRecord(const MG_Pipe::MGPipeShaderCsoRecord& record, Int blockIndex); + Int ProgramSamplerUnitFromRecord(const MG_Pipe::MGPipeShaderCsoRecord& record, Uint location); +#endif } // namespace PrgramImpl namespace SamplerImpl { @@ -1814,7 +3240,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& stateSamplerObject, + MG_Pipe::MGPipeHandle pushedCso = MG_Pipe::kMGPipeNullHandle); +#else void SyncToBackend(const SharedPtr& stateSamplerObject); +#endif void Bind(Uint unit); Uint GetBackendSamplerId() const; @@ -1824,16 +3275,129 @@ 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); extern Array g_boundSamplersCache; - extern StateBackendObjectRegistry + extern TwinRegistry 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; + 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: @@ -1844,6 +3408,16 @@ namespace MobileGL::MG_Backend::DirectGLES { BackendRenderbufferObject(const BackendRenderbufferObject&) = delete; BackendRenderbufferObject& operator=(const BackendRenderbufferObject&) = delete; void SyncToBackend(const SharedPtr& stateRBOObject); +#if MOBILEGL_PIPE_PUSH + // P5e SEAM (declared by c0e, bodied by fb): the renderbuffer twin of the + // framebuffer overload above. MGPSurface::Res names the renderbuffer and the + // resource record already carries its format, extent and sample count (P4a), so the + // attachment sync needs no frontend RenderbufferObject - which is what deletes the + // one live identity probe left in the attachment path (Managers.cpp's renderbuffer + // cross-check, the sibling that never got the Transport == Monolith gate its + // texture counterpart has). + void SyncToBackendByHandle(MG_Pipe::MGPipeHandle renderbuffer); +#endif Uint GetBackendRenderbufferId() const { return m_backendRBOId; } void Bind() const; @@ -1855,9 +3429,36 @@ 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 StateBackendObjectRegistry + extern TwinRegistry g_backendRenderbufferObjects; } // namespace RenderbufferImpl + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (ct), CONTRACT-P5C.md §5.2: object_death's per-kind release, one entry point for all + // seven kinds for the same reason the notice switch is one - the answer is the same for + // all of them: every holder of the kind's twin table lets go of the twin at this handle. + // Called from ServerVerbSink::OnObjectDeath ON THE APPLY THREAD, with the handle the + // record carried; the client's allocator is never consulted (rule E). Returns whether any + // table released a twin - false for a kind this backend does not twin (Buffer: its death + // crosses as resource_destroy) and for a handle no holder holds, which the kind's own + // delete opcode may already have released (the idempotent-second-path shape the notice + // arms document). + Bool ReleaseTwinsForWireObjectDeath(MG_Pipe::MGPipeHandle handle, MG_Pipe::MGPipeKind kind); +#endif } // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp index d3dde2ce0..c8f0060cf 100644 --- a/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp +++ b/MobileGL/MG_Backend/DirectGLES/MultiDraw.cpp @@ -9,6 +9,9 @@ #include "MultiDraw.h" #include "Managers.h" #include +#include +#include +#include #include #include @@ -41,14 +44,14 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { // verbatim is already "this batch restarts nowhere". Uint32 RestartSentinelFor(GLenum type) { if (ResolveRestartSubstitution(type) != RestartSubstitutionKind::None) { - return MG_State::pGLContext->GetPrimitiveRestartIndex(); + return MGB_CTX->GetPrimitiveRestartIndex(); } return MG_Util::FixedRestartIndexForGLType(type); } Bool RestartActive() { - return MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); + return MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); } // Vertices per primitive for the modes whose sub-draws may be concatenated into a @@ -81,19 +84,172 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { constexpr SizeT kMaxComputeWorkGroups = 65535; constexpr SizeT kMaxComputeFlattenedIndices = kMaxComputeWorkGroups * kComputeWorkGroupSize; - Uint BoundDrawIndirectBufferId() { - const auto& indirect = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); - if (!indirect) return 0; - const auto* resource = BufferImpl::EnsureBufferResource(indirect); - return resource ? resource->id : 0; + // BoundDrawIndirectBufferId MOVED (P5e ra2, ID-136) to sit beside BoundIndexBufferId, + // which is the same question about the other target and now has the same two arms. + + // --------------------------------------------------------------------------- + // The bound index buffer, and WHICH SIDE ANSWERS FOR IT + // + // P5e (mv), CONTRACT-P5E §5.1 + §5.8 (ruling ID-81): this file asks the bound element + // array buffer exactly three questions - "is one bound at all", "what GL name did + // PrepareForDraw leave on GL_ELEMENT_ARRAY_BUFFER (and how big is its store)", and + // "give me its bytes on the CPU" - and until now it asked all three of the FRONTEND + // VAO, on the apply thread, once per indexed multi-draw. That was the last unguarded + // `MGB_CTX->GetBoundVertexArray()` in the backend: package vi retired the ordinary + // draw path's copy (DirectGLES.cpp's SyncCurrentVertexAttributeValues / PrepareForDraw) + // and this one survived only because these batches used to die earlier, on the + // framebuffer row fb has since retired. + // + // THE ARM IS STATED, NEVER INFERRED. BufferImpl::VertexInputReadsRecords() is the + // family's own selector and reduces to `Transport != Monolith && the vertex-input bit`; + // it is the same conjunction RefuseElementArrayBufferFromTheFrontend guards the + // single-draw path with. Nothing below decides an arm by noticing that a handle or a + // pointer happens to be null - conflating "a handle was noted" with "the record arm is + // selected" is what ID-107 cost this phase 137 scenarios. + // --------------------------------------------------------------------------- + + // How much of the index buffer a caller needs. DELIBERATELY not nested levels: each + // value is exactly the work its original call site did, in its original order, so the + // monolith arm makes the same calls in the same sequence it made before this package. + enum class IndexBufferQuestion { + Presence, // is an element array buffer bound at all + DriverName, // + the GL name currently bound to GL_ELEMENT_ARRAY_BUFFER + DriverNameAndSize,// + the store's size in bytes (the compute tier's source) + HostBytes, // a CPU-readable copy of the whole store, and its size + }; + + struct BoundIndexBufferView { + Bool Present = false; + Uint Id = 0; // DriverName*: 0 when there is no backend store yet + SizeT Size = 0; // DriverNameAndSize / HostBytes + const Uint8* HostBytes = nullptr; // HostBytes: null when there is no CPU copy + }; + +#if MOBILEGL_BUILD_DISAGGREGATED + // A missing record on the handle arm is a NAMED refusal and never a quiet fall-back to + // the frontend (TASK-mv requirement 2; the shape is Managers.cpp's + // RefuseNullFrontendTextureOffTheHandleArm). The frontend element slot is not a second + // opinion on this side: it answers for whatever the CLIENT has bound NOW, which is a + // later draw than the one being applied. Drawing a batch from it would be a silently + // wrong picture, which is precisely what MultiDrawScenario's batch-matches-unrolled + // assertions exist to catch and what a fall-back would hide from them. + [[noreturn]] void RefuseMissingIndexBufferRecord(const char* entry, MG_Pipe::MGPipeHandle res, + const char* missing) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"multidraw-index-buffer-arm\"} - %s found no %s for " + "the index buffer {%u, %u} this batch's record named. The index buffer of this " + "family is MGPipeApplier().IndexBuffer.Res (CONTRACT-P5E §5.1) and the arm is " + "selected by Transport != Monolith AND the vertex-input subsystem bit (§5.8, " + "ID-81); falling back to the frontend VAO's element slot here would draw this " + "multi-draw from whatever the CLIENT has bound now", + entry, missing, res.Slot, res.Gen); + std::abort(); + } + + // ---- P5e (ra2), ID-136: THE SAME REFUSAL FOR THE INDIRECT TARGET ------------------- + // + // A sibling of mv's rather than a second idiom, because it is the same sentence about + // the other buffer target: a missing record on the handle arm aborts by name and never + // falls back to the frontend binding slot, which answers for whatever the CLIENT has + // bound NOW - a later verb than the one being applied. + [[noreturn]] void RefuseMissingIndirectBufferRecord(const char* entry, + MG_Pipe::MGPipeHandle res) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"multidraw-indirect-buffer-arm\"} - %s found no " + "backend resource for the indirect buffer {%u, %u} this verb's record named. " + "The indirect buffer of this family is MGPipeApplier().VerbIndirectBuffer " + "(CONTRACT-P5E §2.1) and the arm is selected by Transport != Monolith " + "(ID-81); falling back to the frontend GL_DRAW_INDIRECT_BUFFER slot here " + "would restore whatever the CLIENT has bound now", + entry, res.Slot, res.Gen); + std::abort(); } - const SharedPtr& BoundIndexBuffer() { - static const SharedPtr none; - const auto& vao = MG_State::pGLContext->GetBoundVertexArray(); - if (!vao) return none; - return vao->GetIndexBufferBindingSlot().GetBoundObject(); + // Does the applier's record say the application SUPPLIED this store's content? The M-3 + // rule ScopedRestartIndexSubstitution spells: for a supplied store a coverage gap in the + // server shadow is a missing record and Fatal by name; for one the application ORPHANED + // the gap is its own undefined content, and reading the shadow's zero-fill is exactly + // what the monolith arm's MappedData() hands back. + Bool IndexBufferRecordHasDefinedContent(const MG_Pipe::MGPipeApplierState& st, + MG_Pipe::MGPipeHandle res) { + if (res.Slot >= st.Resources.size()) return false; + const auto& candidate = st.Resources[res.Slot]; + if (!candidate.Live || candidate.Gen != res.Gen) return false; + return candidate.Desc.HasDefinedContent != 0; + } +#endif + + BoundIndexBufferView ResolveBoundIndexBuffer(IndexBufferQuestion question, const char* entry) { + BoundIndexBufferView view; +#if MOBILEGL_BUILD_DISAGGREGATED + if (BufferImpl::VertexInputReadsRecords()) { + const auto& st = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPipeHandle res = BufferImpl::ResolveDrawIndexBufferFromRecord(st).Res; + // A null Res is "no element array buffer bound", exactly as a null frontend slot + // is on the arm below - the batch's indices are a client array, and every caller + // already has an arm for that. It is NOT the arm test; the arm was decided above. + if (MG_Pipe::MGPipeHandleIsNull(res)) return view; + view.Present = true; + if (question == IndexBufferQuestion::Presence) return view; + + if (question == IndexBufferQuestion::HostBytes) { + // No Sync* pair here and none is missing: on this side the applier has + // already consumed the persistent-map blocks and the shader writebacks for + // this resource before the draw verb replayed, so the server's staged shadow + // IS the synced copy. That is also why this arm cannot be expressed as "find + // the object and run the monolith body". + auto* resource = BufferImpl::FindBufferResourceForHandle(res); + if (resource == nullptr) RefuseMissingIndexBufferRecord(entry, res, "backend resource"); + view.Size = BufferImpl::ResourceWidthForHandle(res); + view.HostBytes = resource->hostBytes; + if (view.HostBytes != nullptr && IndexBufferRecordHasDefinedContent(st, res)) { + BufferImpl::RequireStagedCoverage(*resource, view.HostBytes, 0, view.Size, + "multidraw_index_rebase"); + } + // A null HostBytes is not a refusal: MappedData() on the monolith arm is + // equally allowed to be null (an adopted coherent map keeps its bytes + // elsewhere), and the one caller that asks declines the tier for it. + return view; + } + + auto* resource = BufferImpl::EnsureBufferResourceForHandle(nullptr, res); + if (resource == nullptr) RefuseMissingIndexBufferRecord(entry, res, "backend resource"); + view.Id = resource->id; + if (question == IndexBufferQuestion::DriverNameAndSize) { + view.Size = BufferImpl::ResourceWidthForHandle(res); + } + return view; + } +#endif + // MONOLITH GLUE from here down, token for token what each call site did before. + const auto& vao = MGB_CTX->GetBoundVertexArray(); + if (!vao) return view; + const auto& ibo = vao->GetIndexBufferBindingSlot().GetBoundObject(); + if (!ibo) return view; + view.Present = true; + switch (question) { + case IndexBufferQuestion::Presence: + break; + case IndexBufferQuestion::DriverName: { + const auto* resource = BufferImpl::EnsureBufferResource(ibo); + view.Id = resource ? resource->id : 0; + break; + } + case IndexBufferQuestion::DriverNameAndSize: { + const auto* resource = BufferImpl::EnsureBufferResource(ibo); + view.Id = resource ? resource->id : 0; + view.Size = ibo->GetSize(); + break; + } + case IndexBufferQuestion::HostBytes: + // The shadow is the source of truth for CPU reads, but a persistent map or + // a shader write may have moved past it since the last sync. + ibo->SyncPersistentMappedRange(); + ibo->SyncGpuWrites(); + view.HostBytes = ibo->MappedData(); + view.Size = ibo->GetSize(); + break; + } + (void)entry; + return view; } // The GL name PrepareForDraw left on GL_ELEMENT_ARRAY_BUFFER, i.e. what a tier @@ -101,9 +257,57 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { // matters beyond tidiness: the VAO twin memoises that it already synced this // index binding and will not re-issue it on the next draw. Uint BoundIndexBufferId() { - const auto& ibo = BoundIndexBuffer(); - if (!ibo) return 0; - const auto* resource = BufferImpl::EnsureBufferResource(ibo); + return ResolveBoundIndexBuffer(IndexBufferQuestion::DriverName, "BoundIndexBufferId").Id; + } + + // The GL name on GL_DRAW_INDIRECT_BUFFER, i.e. what a tier that swaps in its own scratch + // COMMAND buffer has to put back - the exact twin of BoundIndexBufferId above, and now + // with the same two arms. + // + // ---- P5e (ra2), ID-136: THE SEAT THIS FILE'S OWN RULE HAD MISSED ------------------- + // + // This was the LAST unguarded frontend read on the multi-draw apply path, and it sat + // twenty lines above the function that retired its neighbour. It asked + // `MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect)` and then + // `EnsureBufferResource()` - a registry lookup keyed by the client's + // identity, which is §4.4's rule and not only the allocator's. Under run-ahead that is + // `Fatal{UnmigratedPipeInput, "GetBufferBindingSlot@DrawArrays"}` on every batch the + // indirect tiers execute: 18 lane entries, and the only thing standing between the flip + // and a green lane once the fill race was fixed. + // + // WHAT IT IS NOT: a data dependency. The tier does not want the client's indirect + // buffer - it never reads a byte of it. It binds its OWN scratch command buffer + // (g_indirectCommands) and wants to put back the name that was there. So the answer is + // not "migrate the value" (which is what P8 owes for the ordinary indirect draw path); + // it is "ask the side that did the binding". ID-133's escalation (iii) legalised the + // pull instead, at the price of a rendezvous on every plain glMultiDraw* on the DEFAULT + // tier - a real cost on the shipping arm, paid to make a lane green. ID-136 withdraws + // that escalation and retires the read, in the commit that adds this arm. + // + // WHY MGPipeApplier().VerbIndirectBuffer IS THE WHOLE ANSWER ON THIS ARM: with a + // transport, the ONLY writer of this process's GL_DRAW_INDIRECT_BUFFER outside this + // function is DirectGLES.cpp's DrawSyncBit::IndirectBuffer arm, which binds from exactly + // that handle and nothing else. So "what was bound" IS "what the verb's record named", + // and a null handle is "this verb bound none" - the same 0 the monolith arm returns for + // an empty slot, and not the arm test (ID-110: the arm was decided by the transport + // above, never inferred from a null). + Uint BoundDrawIndirectBufferId() { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + const MG_Pipe::MGPipeHandle res = MG_Pipe::MGPipeApplier().VerbIndirectBuffer; + if (MG_Pipe::MGPipeHandleIsNull(res)) return 0; + auto* resource = BufferImpl::EnsureBufferResourceForHandle(nullptr, res); + if (resource == nullptr) { + RefuseMissingIndirectBufferRecord("BoundDrawIndirectBufferId", res); + } + return resource->id; + } +#endif + // MONOLITH GLUE from here down, token for token what this function did before. + const auto& indirect = + MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + if (!indirect) return 0; + const auto* resource = BufferImpl::EnsureBufferResource(indirect); return resource ? resource->id : 0; } @@ -156,7 +360,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { // are bound as storage blocks. Respecifies rather than sub-updates: glBufferData // orphans the previous store, so the upload never waits on a dispatch still reading // the old contents out of the same name. - Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data) { + // statsClass: which MGPipe byte population these bytes belong to. Counted here + // rather than at the four call sites so a new tier cannot forget it. + Bool UploadScratch(ScratchBuffer& buffer, SizeT bytes, const void* data, + MG_Util::PipeStats::ByteClass statsClass) { if (bytes == 0) return true; if (!EnsureScratchName(buffer)) return false; BufferImpl::BindBufferId(BufferImpl::TempBufferTarget, buffer.id); @@ -169,6 +376,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { buffer.cursor = 0; if (data) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(statsClass, static_cast(bytes)); + } } return true; } @@ -183,7 +393,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { constexpr SizeT kRingAlignment = 16; // >= 4, so both command and uint32-index offsets stay legal constexpr SizeT kMinRingBytes = 1u << 16; - Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, SizeT& outOffset) { + Bool UploadScratchRing(ScratchBuffer& buffer, SizeT bytes, const void* data, + MG_Util::PipeStats::ByteClass statsClass, SizeT& outOffset) { outOffset = 0; if (bytes == 0) return true; if (!EnsureScratchName(buffer)) return false; @@ -207,6 +418,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { if (data) { g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, static_cast(outOffset), static_cast(bytes), data); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(statsClass, static_cast(bytes)); + } } buffer.cursor += aligned; return true; @@ -349,13 +563,18 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { } } - // CPU-readable bytes of one sub-draw's indices, from the frontend shadow of the - // bound index buffer or straight from the client array. Null when the sub-draw - // would read outside the buffer. - const Uint8* ResolveSubDrawIndices(const SharedPtr& indexBuffer, - const Uint8* indexBufferBytes, SizeT indexBufferSize, const void* indices, - SizeT indexCount, SizeT indexSize) { - if (!indexBuffer) { + // CPU-readable bytes of one sub-draw's indices, from the CPU copy of the bound index + // buffer (the frontend shadow on the monolith arm, the server's staged shadow on the + // record arm) or straight from the client array. Null when the sub-draw would read + // outside the buffer. + // + // P5e (mv): `hasIndexBuffer` is a Bool and not the frontend object it used to be, + // because presence is the only thing this function ever asked of it - and on the record + // arm there is no such object on this side to hand it. + const Uint8* ResolveSubDrawIndices(Bool hasIndexBuffer, const Uint8* indexBufferBytes, + SizeT indexBufferSize, const void* indices, SizeT indexCount, + SizeT indexSize) { + if (!hasIndexBuffer) { return static_cast(indices); } if (!indexBufferBytes) return nullptr; @@ -398,8 +617,7 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { if (indexSize == 0) return false; // Indirect commands address indices as an element offset into the bound element // array buffer, and an indirect draw is not defined without one. - const auto& indexBuffer = BoundIndexBuffer(); - if (!indexBuffer) return false; + if (!ResolveBoundIndexBuffer(IndexBufferQuestion::Presence, "RunIndirect").Present) return false; g_commandStaging.resize(static_cast(drawcount)); for (GLsizei i = 0; i < drawcount; ++i) { @@ -417,7 +635,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { const SizeT commandBytes = g_commandStaging.size() * sizeof(DrawElementsIndirectCommand); SizeT commandBase = 0; - if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), commandBase)) { + if (!UploadScratchRing(g_indirectCommands, commandBytes, g_commandStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndirectCmd, commandBase)) { return false; } @@ -489,17 +708,10 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { if (total == 0) return true; if (total > kMaxFlattenedIndices) return false; - const auto& indexBuffer = BoundIndexBuffer(); - const Uint8* indexBufferBytes = nullptr; - SizeT indexBufferSize = 0; - if (indexBuffer) { - // The shadow is the source of truth for CPU reads, but a persistent map or - // a shader write may have moved past it since the last sync. - indexBuffer->SyncPersistentMappedRange(); - indexBuffer->SyncGpuWrites(); - indexBufferBytes = indexBuffer->MappedData(); - indexBufferSize = indexBuffer->GetSize(); - } + const BoundIndexBufferView indexBuffer = + ResolveBoundIndexBuffer(IndexBufferQuestion::HostBytes, "RunRebasedDrawElements"); + const Uint8* const indexBufferBytes = indexBuffer.HostBytes; + const SizeT indexBufferSize = indexBuffer.Size; const Bool restartActive = RestartActive(); const Uint32 restartSentinel = RestartSentinelFor(type); @@ -518,8 +730,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { for (GLsizei i = 0; i < drawcount; ++i) { if (count[i] <= 0) continue; const SizeT subDrawCount = static_cast(count[i]); - const Uint8* source = ResolveSubDrawIndices(indexBuffer, indexBufferBytes, indexBufferSize, indices[i], - subDrawCount, indexSize); + const Uint8* source = ResolveSubDrawIndices(indexBuffer.Present, indexBufferBytes, indexBufferSize, + indices[i], subDrawCount, indexSize); if (!source) { MGLOG_E_ONCE("DirectGLES multi-draw (drawelements tier): sub-draw %d reads outside the bound index " "buffer; skipping the batch", @@ -532,7 +744,8 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl { } SizeT indexBase = 0; - if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), indexBase)) { + if (!UploadScratchRing(g_rebasedIndices, total * sizeof(Uint32), g_indexStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndexClient, indexBase)) { return false; } @@ -700,16 +913,18 @@ void main() { // The shader reads the source indices as a storage buffer, so there has to be // a real buffer to read - a client-memory index array has none. - const auto& indexBuffer = BoundIndexBuffer(); - if (!indexBuffer) return; + if (!ResolveBoundIndexBuffer(IndexBufferQuestion::Presence, "FlattenWithCompute").Present) return; // A dispatch inside an open capture span is not legal, and the span would also // observe one merged draw rather than the batch it asked for. if (XfbImpl::IsCaptureSpanOpen()) return; - auto* sourceResource = BufferImpl::EnsureBufferResource(indexBuffer); - if (!sourceResource || sourceResource->id == 0) return; - const SizeT sourceSize = indexBuffer->GetSize(); + // Asked a second time, and one question later: the ensure below may do GL work, so + // it stays BEHIND the capture-span check exactly as it was before P5e (mv). + const BoundIndexBufferView source = + ResolveBoundIndexBuffer(IndexBufferQuestion::DriverNameAndSize, "FlattenWithCompute"); + if (source.Id == 0) return; + const SizeT sourceSize = source.Size; // std430 addresses the source as uint[]; a tail shorter than a word is not // reachable, so a narrow index type needs a word-multiple buffer. if (indexSize < 4 && (sourceSize % 4) != 0) return; @@ -737,12 +952,18 @@ void main() { if (total == 0) return; // nothing to draw; the ordinary tiers no-op just as well if (!EnsureComputeProgram()) return; - if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data())) { + if (!UploadScratch(g_drawInfo, g_drawInfoStaging.size() * sizeof(Uint32), g_drawInfoStaging.data(), + MG_Util::PipeStats::ByteClass::StageIndirectCmd)) { + return; + } + // data == nullptr: pure respecify, the compute pass writes the contents, so no + // host bytes cross here and nothing is counted. + if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr, + MG_Util::PipeStats::ByteClass::StageIndexClient)) { return; } - if (!UploadScratch(g_flattenedIndices, total * sizeof(Uint32), nullptr)) return; - BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 0, sourceResource->id); + BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 0, source.Id); BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 1, g_drawInfo.id); BufferImpl::BindBufferBaseCached(GL_SHADER_STORAGE_BUFFER, 2, g_flattenedIndices.id); @@ -886,7 +1107,8 @@ void main() { const Bool arbitraryRestart = restartKind == RestartSubstitutionKind::RewriteIndices; const ScopedSuppressedPrimitiveRestart restartCapOverride(restartKind); - const Bool hasIndexBuffer = BoundIndexBuffer() != nullptr; + const Bool hasIndexBuffer = + ResolveBoundIndexBuffer(IndexBufferQuestion::Presence, "DrawElementsBatch").Present; // The compute tier dispatches BEFORE the draw state is established: doing it // afterwards would mean unpicking the program, SSBO and index bindings diff --git a/MobileGL/MG_Backend/DirectGLES/SlotTables.h b/MobileGL/MG_Backend/DirectGLES/SlotTables.h new file mode 100644 index 000000000..3ae3d4646 --- /dev/null +++ b/MobileGL/MG_Backend/DirectGLES/SlotTables.h @@ -0,0 +1,795 @@ +// MobileGL - MobileGL/MG_Backend/DirectGLES/SlotTables.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include + +#if MOBILEGL_PIPE_PUSH +#include +#endif + +// Espryt 0b, the first Track H slice: the DENSE, {slot, gen}-keyed twin table that replaces +// StateBackendObjectRegistry's UnorderedMap. +// +// What changes, and why each of them is the point: +// +// * The KEY stops being a frontend heap address. It is MGPipeHandle{Slot, Gen}, minted by the +// client's MGPipeSlotAllocator off the frontend object's GetLifetimeId(). A recycled heap +// address cannot reproduce a handle, so the weak_ptr the registry carried per entry purely +// to catch that (its Entry::stateRef, used as an IDENTITY test) stops being an identity +// mechanism, and OwnerEquals / TwinLookupMemo x3 / UnitSamplerLookupMemo's owner compare all +// lose their reason to exist. +// * The lookup stops being a hash probe into an open-addressed map and becomes one bounds +// check plus one array index, so a returned BackendPtr* is NOT invalidated by the next Find +// on the table. That kills the hazard Managers.h documents at length, and with it the +// by-value copy plus second Find that SyncTextureObjectToBackend paid to survive it. +// * Slots are dense per kind, which is what lets the server side (ARCHITECTURE.md 10.1, +// MG_Remote/Server/PipeObjectTables) be an array rather than an object graph. +// +// Death is ANNOUNCED, and that is what lets this table have no garbage collector - the +// deliverable ROADMAP.md:18 spells "GC" in and the one D13 makes a precondition of the switch- +// over. All six re-keyed object classes raise MG_State::GLState::NotifyStateObjectDestroyed() +// from their destructor (BufferBackendOps' shape, one entry point for six kinds), the backend +// consumes it in Managers.cpp, and OnFrontendObjectDestroyed() below drops the twin in EVERY +// table of the kind and returns the slot, at the moment the frontend object's last SharedPtr +// goes. So: +// * there is NO draw-path tick, NO creation tick and NO sweep of any kind on this arm. The +// seven CollectGarbageIfNeeded call sites in DirectGLES.cpp drive the LEGACY registry only; +// * a twin, and the driver storage it owns, is freed when the application lets go of the +// object rather than up to 64 creations or 1024 draw ticks later. That is what +// Managers.h's "dead gigabytes" note asked for. +// +// EVERY HOLDER OF THE KIND, not one. Two live tables of one kind is a real configuration - the +// ScopedDirectGLESTextureBindings fixture keeps a by-value copy of the Texture registry for the +// length of a test, and a context reset does the same in reverse - and the slot allocator +// erases its lifetimeId -> slot mapping on Free, so a notice delivered to one holder and +// resolved again by the next would find nothing to resolve. Every table therefore links itself +// into a per-table-type list at construction and out at destruction, and one notice resolves +// the handle ONCE, drops the twin in each holder BY HANDLE, and frees the slot once, last. No +// holder can be left naming a live entry for a dead object, and there is nothing a sweep could +// still find. (The list is per table TYPE; the kind is the type's template parameter, and each +// of the six kinds has exactly one table type in this backend. Magma's subsystem-4 table mints +// out of its own per-renderer allocator, not MGPipeSlots(), so it is not a holder here.) +// +// The weak_ptr per entry survives as the MONOLITH-GLUE half of the table's identity: the +// minting GetOrCreate stores it, NoteStateForHandle/StateForHandle read it, and the legacy +// death notice walks it. It is never an identity test - that is what Gen is for - and it is +// never read to decide whether an entry is dead: a destructor that runs after exit() has begun +// has its notice dropped by InProcessTeardown(), and that twin is then a DELIBERATE leak (the +// process is exiting, the driver reclaims the object, and a twin destructor must not call into +// a driver that may already be unloaded), not something to be collected later. +// +// P5e (id, CONTRACT-P5E §4.1): ForEachLive() NO LONGER HANDS IT OUT. It answers +// fn(MGPipeHandle, const BackendPtr&), because a walk that produced a frontend SharedPtr out +// of server memory was the last place the server could hold one across records - and a caller +// that still needs the object (the detach walk) asks StateForHandle for it BY HANDLE, inside +// the scope that names the debt, so the read is one greppable site rather than a property of +// the iteration. +// +// P3+ DEBT, recorded rather than hidden: this header is under MG_Backend/ and it MINTS +// handles (MGPipeSlots().Acquire below) off a frontend SharedPtr's GetLifetimeId(). +// MGPipeHandles.h:13-16 says a handle is minted by the CLIENT and never by the server, and +// under a real split neither the frontend object nor its lifetime id exists on this side of +// the wire. This is monolith glue: the minting and the lifetimeId -> handle resolution both +// belong on the client, and the backend should receive the handle in the verb payload. It is +// NOT part of "Track H done" and check_include_closure.py does not probe MG_Backend headers, +// so nothing catches it automatically. +// +// P5c (hd, CONTRACT-P5C §3.1) is what gives the debt teeth: with an active transport the +// minting GetOrCreate, HandleOf and OnFrontendObjectDestroyed raise +// Fatal{RoleViolation, "MGPipeSlots"} when reached from the apply thread (the same check the +// allocator's own three entries carry, repeated here so the refusal names this surface), and +// the split paths resolve through GetOrCreate(handle) / ReleaseByHandle instead. +// +// P5e (id, CONTRACT-P5E §4.1 / §5.8) SETTLES WHAT HAPPENS TO THE FRONTEND-KEYED HALF: it is +// NOT deleted. The push build under Transport=monolith keeps its frontend arms token for token +// (ruling 1), which is what the MOBILEGL_PIPE_VERIFY comparator needs and what makes +// MOBILEGL_IPC_RUN_AHEAD=0 a pure wait-rule A/B on identical server code. So +// GetOrCreate(StatePtr), Find(StateObject*), HandleOf, NoteStateForHandle/StateForHandle, +// Entry::stateRef and the resolution memo all survive - as MONOLITH GLUE, each of them a NAMED +// FATAL the moment it is reached from an apply thread that is applying an UNBARRIERED record +// (§4.4: the client's wait is the only thing that makes such a read stable, and an unbarriered +// record has none). The refusal is raised either by the allocator guard, for the three members +// that call the allocator, or by MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply for the +// three that do not. There is no silent path: the phase's claim is that the twin is resolved +// from the handle the record carried, and a surface that quietly answered from a frontend +// pointer instead would make that claim untestable. +namespace MobileGL::MG_Backend::DirectGLES { + +#if MOBILEGL_PIPE_PUSH + + // Declared in Managers.h as well; repeated here because this header is included from it + // before that declaration, and the table below is the arming site on this arm (D13: "the + // arming site moves to the slot table's first insertion"). + void EnsureProcessTeardownSentinel(); + + // What the two knobs add up to. Split out as a PURE function of them so a test can drive + // every combination without needing a process per combination. + enum class EsprytSlotArmVerdict { + Handles, // kMGPipeSubsystemEsprytSlots is set: the {slot, gen} tables run. + Legacy, // the bit is clear and the legacy address-keyed registry is reachable. + NoArm, // the bit is clear AND MOBILEGL_PIPE_LEGACY_MEMOS=0 made the legacy arm + // unreachable, so the operator asked for a configuration with no arm at all. + }; + + EsprytSlotArmVerdict ClassifyEsprytSlotArm(Bool subsystemBitSet, Bool legacyMemosEnabled); + + // This process's verdict, read off MG_Config::Features. Latches nothing and stops nothing. + EsprytSlotArmVerdict CurrentEsprytSlotArmVerdict(); + + // Says, at backend bring-up, that the knobs leave no arm - and does NOT stop. + // + // The stop cannot live here, and that is the whole point of the split. Backend context + // creation runs inside eglMakeCurrent, and the integration harness pre-flights exactly that + // sequence in a FORKED CHILD (MG_IntegrationTest/Harness/HeadlessGL.cpp): a child that dies + // on a signal is reported as "no usable GPU/display/ICD" and every scenario in the lane is + // SKIPPED - i.e. the lane goes green having run nothing, on the very pair of env vars the + // D14/D18 A/B is driven with, which is what ROADMAP.md:7 forbids. So bring-up only + // DIAGNOSES; the stop is raised by ResolveEsprytSlotTablesArm() at the first twin lookup, + // which happens in the test body where the harness reports it as a failure. + // + // The CALL SITE (InitDisplayAndContext in DirectGLES.cpp) is pinned by + // DirectGLESSlotTable.EglBringUpUnderTheArmlessKnobPairReturnsInsteadOfStopping, which runs + // the real bring-up entry point under the pair in a forked child: edit that site back to + // ResolveEsprytSlotTablesArm() and the case fails naming both knobs. + void DiagnoseEsprytSlotArm(); + + // Reads the config, logs, installs the death-notice consumer, and STOPS when the operator + // left no arm at all. Cold: called exactly once per process, from the latch below - i.e. at + // the first twin lookup, which is the first moment an arm is actually needed. A process + // that never twins anything needs no arm and is not stopped. + Bool ResolveEsprytSlotTablesArm(); + + // True when this process runs the {slot, gen} arm. Fixed for the life of the process: the + // two arms hold their twins in different containers, so flipping mid-run would strand them. + // + // INLINE on purpose. Every Find / GetOrCreate / HandleOf / ForEachLive on the twin tables + // consults it, i.e. it is on the per-draw path several times per draw. As an out-of-line + // function in Managers.cpp (no LTO in any shipped configuration) that was a call through + // the PLT per lookup; here the caller sees a guard-variable load and a perfectly-predicted + // branch, and the arm dispatch folds into the caller. + inline Bool EsprytSlotTablesEnabled() { + static const Bool enabled = ResolveEsprytSlotTablesArm(); + return enabled; + } + + template + class BackendSlotTable { + public: + using StatePtr = SharedPtr; + using StateWeakPtr = std::weak_ptr; + using BackendPtr = SharedPtr; + + // The largest slot index this table will grow to for a handle that ARRIVED in a call's + // payload. Slots are dense and allocated per kind, so a million of one kind is already + // far past any application's live object count; the cap is here because the alternative + // is letting a corrupt 32-bit slot decide a vector resize. See GetOrCreate(MGPipeHandle). + static constexpr Uint32 kMaxHandleSlot = 1u << 20; + + // P5e (id), CONTRACT-P5E §4.3: THE COMPOSITE BAND, and it is a P5e PREREQUISITE rather + // than a follow-up. ShaderCso's top 1/16 of slot space (from + // kMGPipeShaderCsoCompositeSlotBase = 983040) is the program-pipeline composites' + // (MGPipeHandles.h); EntryAt below indexes m_slots BY SLOT and resizes to it, so a + // single composite used to grow g_backendProgramObjects to ~983k entries of ~40 B - + // ~40 MB for one program pipeline. Today that is reachable only through + // GetOrCreate(StatePtr) for a composite program; after the rekey the by-handle + // resolution IS the ordinary path, so every composite bind would pay it. + // + // A SECOND VECTOR RATHER THAN MORE OF THE FIRST, exactly as the allocator + // (SlotAllocator.h's KindState::BandSlots) and the applier (PipeApply.h's + // CompositeShaderCsos) already do, and for the same reason: both spaces stay dense + // against their own high-water mark, which is the property that lets the server index + // rather than hash. The band is EMPTY for every kind but ShaderCso and costs those + // kinds one empty Vector per table. + static constexpr Bool kHasCompositeBand = (kKind == MG_Pipe::MGPipeKind::ShaderCso); + static constexpr SizeT kMaxBandSlots = + MG_Pipe::kMGPipeShaderCsoSlotLimit - MG_Pipe::kMGPipeShaderCsoCompositeSlotBase; + + struct Entry { + BackendPtr backend; + // THE MONOLITH-GLUE HALF of the entry (P5e, CONTRACT-P5E §4.1): written by the + // minting GetOrCreate and by NoteStateForHandle, read only by StateForHandle and + // the legacy death notice. Never compared against another object to decide identity + // - that is what Gen is for - never dereferenced for its address, and never read to + // decide whether the slot is dead: death is announced, not discovered. ForEachLive + // stopped reading it at P5e: a walk that handed a frontend SharedPtr out of server + // memory on every step was the last place the server could hold one across records. + StateWeakPtr stateRef; + // The generation this entry's twin was built for. An entry whose Gen no longer + // matches the allocator's is a twin of the slot's PREVIOUS owner. + Uint32 Gen = 0; + Bool Live = false; + }; + + // Every constructor links the table into the per-type holder list and the destructor + // unlinks it, so a by-value copy (the ScopedDirectGLESTextureBindings fixture's saved + // registry) is a holder for exactly as long as it exists. Copy and move carry the + // ENTRIES and the memo; the links are the table's own and are never copied. + BackendSlotTable() { LinkHolder(); } + BackendSlotTable(const BackendSlotTable& other): + m_slots(other.m_slots), + m_band(other.m_band), + m_nullTwin(other.m_nullTwin), + m_memoLifetimeId(other.m_memoLifetimeId), + m_memoHandle(other.m_memoHandle) { + LinkHolder(); + } + BackendSlotTable(BackendSlotTable&& other) noexcept: + m_slots(std::move(other.m_slots)), + m_band(std::move(other.m_band)), + m_nullTwin(std::move(other.m_nullTwin)), + m_memoLifetimeId(other.m_memoLifetimeId), + m_memoHandle(other.m_memoHandle) { + other.m_slots.clear(); + other.m_band.clear(); + other.ForgetHandle(); + LinkHolder(); + } + BackendSlotTable& operator=(const BackendSlotTable& other) { + if (this != &other) { + m_slots = other.m_slots; + m_band = other.m_band; + m_nullTwin = other.m_nullTwin; + m_memoLifetimeId = other.m_memoLifetimeId; + m_memoHandle = other.m_memoHandle; + } + return *this; + } + BackendSlotTable& operator=(BackendSlotTable&& other) noexcept { + if (this != &other) { + m_slots = std::move(other.m_slots); + m_band = std::move(other.m_band); + m_nullTwin = std::move(other.m_nullTwin); + m_memoLifetimeId = other.m_memoLifetimeId; + m_memoHandle = other.m_memoHandle; + other.m_slots.clear(); + other.m_band.clear(); + other.ForgetHandle(); + } + return *this; + } + ~BackendSlotTable() { UnlinkHolder(); } + + // Resolve-or-create. The handle comes from the client allocator keyed on the frontend + // object's lifetime id, so two calls for the same live object always land on the same + // slot, and a successor object at the same heap address never does. + BackendPtr& GetOrCreate(const StatePtr& stateObj) { + // No assert on null here, unlike the map arm: null is TOLERATED, so a DEBUG build + // must not trap where the release build quietly does the documented thing. + if (stateObj == nullptr) { + // The registry this replaces inserted a null KEY and handed back that entry's + // twin (DirectGLES.cpp's SyncTextureObjectToBackend documents relying on + // exactly that tolerance), so a release build never dereferenced null here. + // Keep the shape exactly, INCLUDING across calls: the map kept its null-keyed + // entry, so a second null call was handed the same twin the first one got. + // Resetting here instead would have destroyed it - an arm difference in the one + // path that documents relying on this. One per-table parking slot, never live, + // never handed a handle, because a null object has no identity and + // therefore cannot have a {slot, gen}. + return m_nullTwin; + } + + // D13: the teardown sentinel is armed by the slot table's first insertion. Twin + // creation is the moment a driver-owned id starts needing a guarded destructor; + // this is the cold path, so the once-guard costs nothing per draw. On the legacy + // arm StateBackendObjectRegistry::GetOrCreate arms it itself. + EnsureProcessTeardownSentinel(); + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.1): this overload MINTS - it is monolith glue, and with + // an active transport a call from the apply thread is Fatal{RoleViolation, + // "MGPipeSlots"} before the allocator is touched. Split paths call the handle + // overload below. (The check also lives at the allocator's own three entries; it + // is repeated at this entry so the refusal names this surface even if the entry + // set changes.) + // + // P5e (id, §4.4): that one call is also this member's unbarriered-apply refusal. + // The guard exempts a probe only inside a named scope AND while the record being + // applied is barriered, so reaching a MINT from an unbarriered apply aborts here + // whatever scope is open - which is what "kept only as monolith glue" has to mean + // if it is to be checkable. + MG_Pipe::MGPipeRefuseAllocatorFromApplyThread("GetOrCreate(StatePtr)"); +#endif + + const MG_Pipe::MGPipeHandle handle = + MG_Pipe::MGPipeSlots().Acquire(kKind, stateObj->GetLifetimeId()); + MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), + "MGPipe slot space of kind %u is exhausted", + static_cast(kKind)); + Entry& entry = EntryAt(handle.Slot); + if (entry.Live && entry.Gen != handle.Gen) { + // The slot was reclaimed and handed to a new object: the twin at it describes + // driver ids the new state object never made. + entry.backend.reset(); + } + entry.Gen = handle.Gen; + entry.Live = true; + entry.stateRef = stateObj; + // No creation tick and no sweep here. The registry this replaces needed both, + // because nothing told it a texture or a renderbuffer had been DELETED and object + // CHURN rather than draw count is what made that urgent. Every one of the six kinds + // now announces its own death from its destructor, so a dead twin's slot is already + // back before the next creation asks for one. + RememberHandle(stateObj->GetLifetimeId(), handle); + return entry.backend; + } + + // P3a: resolve-or-create BY HANDLE, and it is the shape that discharges the debt this + // header records against itself at the top of the file. + // + // The overload above mints - it calls MGPipeSlots().Acquire off a frontend object's + // lifetime id, from inside MG_Backend - which is monolith glue: a handle is minted by + // the CLIENT, and under a real split neither the object nor its lifetime id exists on + // this side. This overload never touches the allocator at all. The handle ARRIVED, in + // the call's payload, already minted by the side that owns minting; all this does is + // index the slot, notice a generation that no longer matches (the slot was recycled, + // so the twin at it describes driver ids the new resource never made) and hand back + // the twin pointer. FindByHandle beside it is the same shape and already existed. + // + // No StatePtr, therefore no Entry::stateRef unless a caller that holds the object notes + // it (NoteStateForHandle): a handle-keyed entry has no frontend object to weakly hold + // by itself. P5e (§4.1) makes that stop mattering for the iteration - ForEachLive keys + // on Live && backend, so such an entry is VISIBLE to the walk and it is StateForHandle, + // asked by the one caller that needs the object, that answers null for it. Which is the + // same set the old stateRef-locking walk produced, decided at one site instead of in + // the loop. + // + // Death stays ANNOUNCED, as it is on the other overload: for a handle-keyed kind the + // announcement is the family's own destroy call, not the shared death notice, and the + // slot is freed by the CLIENT after that call returns. + // + // UNUSED AT THE CONTRACT COMMIT, deliberately: it is a member of a class template, so + // an uninstantiated one costs nothing anywhere, and the backend package is what gives + // it its first caller. + BackendPtr& GetOrCreate(MG_Pipe::MGPipeHandle handle) { + MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), + "GetOrCreate(handle) named the reserved null handle"); + if (MG_Pipe::MGPipeHandleIsNull(handle)) return m_nullTwin; + + // A slot index that ARRIVED in a payload indexes a vector this call would RESIZE, + // and nothing between the payload and here bounds it: the applier's blob gates sit + // in front of the vertex-input family, not in front of the resource family, which + // dispatches ops->Create(record.Res, ...) straight through. There is no allocator + // constant to check against on this side - the allocator is the client's - so this + // is a sanity cap and is documented as one: kMaxHandleSlot entries of one kind is + // already orders of magnitude past any real GL object count, while a corrupt 32-bit + // slot asks for a four-billion-entry resize. + if (handle.Slot >= kMaxHandleSlot) { + MOBILEGL_ASSERT(false, "GetOrCreate(handle) named slot %u, past this table's %u bound", + handle.Slot, kMaxHandleSlot); + return m_nullTwin; + } + + // Same arming as the minting overload, and for the same reason: twin creation is + // the moment a driver-owned id starts needing a guarded destructor. + EnsureProcessTeardownSentinel(); + + // THE TWO DIRECTIONS ARE NOT SYMMETRIC HERE, where they are on the minting overload. + // There the handle comes straight out of MGPipeSlots().Acquire and can never be + // BEHIND the entry, so a bare `!=` only ever means "the slot was recycled forward". + // Here the handle arrived in a payload, so `handle.Gen < entry.Gen` is a reachable + // input, and adopting it would destroy the INCUMBENT LIVE twin - a driver buffer id, + // a persistent map, a pooled store, released by a defaulted destructor that issues + // no glDeleteBuffers and no pool enrolment - and then stamp the slot back to the + // dead resource's generation, after which the incumbent's own FindByHandle refuses + // it and it is silently handed a fresh, empty twin. That is a leak AND a resource + // that loses its storage with no diagnostic, i.e. the shape commit d7655247 fixed + // and the thing MGPipeHandle::Gen exists to prevent. So: forward is a recycle and + // resets the twin, BACKWARD is refused - which is the same answer FindByHandle + // below already gives the same input. + Entry& entry = EntryAt(handle.Slot); + if (entry.Live && entry.Gen > handle.Gen) { + MOBILEGL_ASSERT(false, + "GetOrCreate(handle) named generation %u at slot %u, which is BEHIND " + "the live entry's %u - refusing rather than destroying the incumbent", + handle.Gen, handle.Slot, entry.Gen); + return m_nullTwin; + } + if (entry.Live && entry.Gen != handle.Gen) entry.backend.reset(); + entry.Gen = handle.Gen; + entry.Live = true; + return entry.backend; + } + + // The generation of the LIVE entry at this slot, or 0 when the slot is out of range or + // holds no live entry. It exists so a caller can DIAGNOSE - in a release build, where + // MOBILEGL_ASSERT is inert - the refusal GetOrCreate(handle) above performs silently. + Uint32 LiveGenAt(Uint32 slot) const { + const Entry* const entry = EntryOrNull(slot); + if (entry == nullptr) return 0; + return entry->Live ? entry->Gen : 0; + } + + // P5c (hd): remember the frontend object a HANDLE-keyed twin was synced from. The + // minting overload sets stateRef itself; the handle overload cannot (no object + // crosses), so a caller that legitimately holds the object - the record-driven sync, + // which arrived holding it through the object-class barrier-pulled rows - notes it + // here. It is what lets a later handle-only resolution (P5c's named blit, and P5e's + // re-typed ForEachLive) reach the frontend object the twin's sync body still walks, + // without probing the client's slot allocator (T2). Same liveness rules as the minted + // stateRef: never an identity test, never read to decide the slot is dead. + // + // P5e (id): the pair is now compiled under MOBILEGL_PIPE_PUSH rather than + // MOBILEGL_BUILD_DISAGGREGATED, because ForEachLive's caller needs it in the + // push-monolith build too, where the note is simply always present. And each half is a + // NAMED FATAL from an unbarriered apply (§4.4): they answer from a frontend SharedPtr + // that only the client's wait pins, so without that wait the object they hand back may + // already be the client's next one. Neither touches the allocator, so the allocator + // guard never sees them - this is their own refusal. + void NoteStateForHandle(MG_Pipe::MGPipeHandle handle, const StatePtr& stateObj) { +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Pipe::MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply("NoteStateForHandle"); +#endif + if (MG_Pipe::MGPipeHandleIsNull(handle)) return; + Entry* const entry = EntryOrNull(handle.Slot); + if (entry == nullptr || !entry->Live || entry->Gen != handle.Gen) return; + entry->stateRef = stateObj; + } + + // The frontend object noted for this handle, or null. The handle answers identity; + // this answers only "which object was this twin last synced from". + StatePtr StateForHandle(MG_Pipe::MGPipeHandle handle) const { +#if MOBILEGL_BUILD_DISAGGREGATED + MG_Pipe::MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply("StateForHandle"); +#endif + if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr; + const Entry* const entry = EntryOrNull(handle.Slot); + if (entry == nullptr || !entry->Live || entry->Gen != handle.Gen) return nullptr; + return entry->stateRef.lock(); + } + + // P3a: the death half of the overload above, for a kind whose announcement is its own + // destroy CALL rather than the shared death notice (D-L). Hands the twin OUT rather + // than destroying it in place, because the caller may still have to decide what + // happens to the driver id it owns - Espryt pools it, deletes it, or parks it on the + // deferred-release list when no context is current on this thread - and every one of + // those outcomes has to be reached with the entry already retired, so a re-entrant + // GetOrCreate from a twin destructor cannot resurrect it. + // + // The slot itself is NOT freed here: it belongs to the kind, and for a handle-keyed + // kind the CLIENT frees it after the destroy call returns (SlotAllocator.h:60 - the + // Gen bump rides the next handout, so a double free cannot skip a generation). An + // entry whose Gen no longer matches is a twin of the slot's previous owner and is + // left alone: the successor's own GetOrCreate resets it. + BackendPtr ReleaseByHandle(MG_Pipe::MGPipeHandle handle) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return BackendPtr{}; + if (m_memoHandle.Slot == handle.Slot) ForgetHandle(); + Entry* const entry = EntryOrNull(handle.Slot); + if (entry == nullptr || !entry->Live || entry->Gen != handle.Gen) return BackendPtr{}; + BackendPtr dead = std::move(entry->backend); + entry->backend.reset(); + entry->stateRef.reset(); + entry->Live = false; + return dead; + } + + // Null when no live twin of this object exists. Unlike the registry's Find this NEVER + // mutates the table, so the returned pointer survives any later Find on it; only a + // GetOrCreate that grows the vector can move it, and callers that hold one across a + // possible insertion still copy the BackendPtr out. + BackendPtr* Find(StateObject* stateObj) { + if (stateObj == nullptr) return nullptr; + return FindByHandle(HandleOf(stateObj)); + } + + const BackendPtr* Find(StateObject* stateObj) const { + return const_cast(this)->Find(stateObj); + } + + BackendPtr* FindByHandle(MG_Pipe::MGPipeHandle handle) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return nullptr; + Entry* const entry = EntryOrNull(handle.Slot); + if (entry == nullptr || !entry->Live || entry->Gen != handle.Gen) return nullptr; + return &entry->backend; + } + + // The handle this object's twin is keyed on, or the null handle. This is what a backend + // memo stores instead of a raw pointer, a GL name or a bare lifetime id. + // + // A NULL answer is never memoised. The memo is per table and the allocator is per + // kind, so with two holders of one kind the OTHER table can be the one that acquires; + // a cached "no handle" here would then outlive the twin's creation over there, and + // nothing on this table's own acquire path would ever refresh it. A miss costs the + // allocator probe it always cost; a hit is refreshed the moment anyone acquires. + MG_Pipe::MGPipeHandle HandleOf(const StateObject* stateObj) const { + if (stateObj == nullptr) return MG_Pipe::kMGPipeNullHandle; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd): same guard as the minting overload - a lifetime-id probe from the + // apply thread is Fatal{RoleViolation, "MGPipeSlots"} with an active transport. + // P5e (id, §4.4): and now also whenever the record being applied is UNBARRIERED, + // scope or no scope. This member and Find(StateObject*) below it are the two the + // per-family packages retire by passing the handle the record carried instead. + MG_Pipe::MGPipeRefuseAllocatorFromApplyThread("HandleOf"); +#endif + const Uint64 lifetimeId = stateObj->GetLifetimeId(); + if (lifetimeId == m_memoLifetimeId) return m_memoHandle; + const MG_Pipe::MGPipeHandle handle = + MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId); + if (!MG_Pipe::MGPipeHandleIsNull(handle)) RememberHandle(lifetimeId, handle); + return handle; + } + + // P2 step e2's backend half. The frontend object with this lifetime id has just been + // DESTROYED: resolve its handle ONCE, drop its twin in EVERY table of this type, and + // return the slot to the allocator - in that order, because the allocator forgets the + // lifetime id on Free and a holder told second could no longer resolve it. + // + // The slot is returned whether or not any holder still had a twin at it: the lifetime + // id is dead and MG_State never hands one out twice, so nothing can acquire it again, + // and a slot minted for it that no table holds (a table reset with `= {}` drops its + // entries without freeing) would otherwise stay allocated for the life of the process. + // + // STATIC, and deliberately so: a notice is about an object, not about a table, and + // "which table holds it" is exactly the question that produced the two-holder leak. + // Returns whether the object had a slot of this kind, i.e. whether anything was freed; + // a second call for the same id answers false because the allocator no longer maps it. + static Bool OnFrontendObjectDestroyed(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd): the Find/Free pair below is monolith-only with an active transport - + // a frontend death is announced to the server by the object_death record (ct), + // and a direct call from the apply thread is Fatal{RoleViolation, "MGPipeSlots"}. + MG_Pipe::MGPipeRefuseAllocatorFromApplyThread("OnFrontendObjectDestroyed"); +#endif + const MG_Pipe::MGPipeHandle handle = + MG_Pipe::MGPipeSlots().FindByLifetimeId(kKind, lifetimeId); + if (MG_Pipe::MGPipeHandleIsNull(handle)) return false; + for (BackendSlotTable* holder = s_firstHolder; holder != nullptr;) { + // The successor is read BEFORE the release: ReleaseTwinAt runs the twin's + // destructor, which is a driver call, and nothing that outlives it may be a + // reference into this holder. + BackendSlotTable* const next = holder->m_nextHolder; + holder->ReleaseTwinAt(handle); + holder = next; + } + MG_Pipe::MGPipeSlots().Free(kKind, handle); + return true; + } + + // P5c (ct), CONTRACT-P5C.md §5.2: the handle-keyed half of the above, for a death that + // arrived AS A WIRE RECORD (object_death) rather than as the shared notice. The + // handle IS the resolution - it crossed in the record's payload - so the client's + // allocator is never asked: under an active transport MGPipeSlots() is a client-only + // surface (rule E, §3.1) and this function runs on the apply thread. Every holder + // lets go exactly as the notice arm does, in the same successor-first order. What + // does NOT happen here is the allocator Free: the slot's owner - the client - + // returned it itself after the record went out (PipeFill.cpp's NotifyAndFree), and a + // double Free would be refused by generation anyway. Idempotent against the kind's + // own delete opcode, exactly as the notice arm is: a twin the delete already released + // fails ReleaseTwinAt's generation check and the walk moves on. + static Bool ReleaseTwinByHandle(MG_Pipe::MGPipeHandle handle) { + if (MG_Pipe::MGPipeHandleIsNull(handle)) return false; + Bool released = false; + for (BackendSlotTable* holder = s_firstHolder; holder != nullptr;) { + BackendSlotTable* const next = holder->m_nextHolder; + released = holder->ReleaseTwinAt(handle) || released; + holder = next; + } + return released; + } + + // How many tables of this type exist right now. For the tests that pin the holder + // list; nothing on a shipping path asks. + static Uint32 HolderCount() { + Uint32 count = 0; + for (const BackendSlotTable* holder = s_firstHolder; holder != nullptr; + holder = holder->m_nextHolder) { + ++count; + } + return count; + } + + // P5e (id), CONTRACT-P5E §4.1: fn(MGPipeHandle, const BackendPtr& twin) over every + // live, twinned entry. Replaces the registry's begin()/end(), whose iterator exposed + // the raw frontend address as the map key - the one place the backend read an identity + // it must not have - and replaces P3a's own fn(StatePtr, BackendPtr), which handed a + // frontend SharedPtr OUT OF SERVER MEMORY on every step. + // + // WHAT THE CALLER LOST AND HOW IT GETS IT BACK, because the two are not the same thing. + // The walk no longer locks Entry::stateRef, so a caller that needs the frontend object + // asks StateForHandle(handle) for it - which is the same weak reference, read at one + // named site inside the scope that owns the debt, instead of at every step of an + // iteration. Coverage is UNCHANGED by construction: an entry ForEachLive used to skip + // because its stateRef did not lock is one whose StateForHandle answers null, and the + // caller skips it there instead. + // + // The handle is the entry's own {slot, gen}, band-aware: a composite ShaderCso's slot + // is reported as kMGPipeShaderCsoCompositeSlotBase + its band index, i.e. the slot the + // client minted, never the index into m_band. + template + void ForEachLive(Fn&& fn) const { + // Index loop and a COPIED twin, not a range-for over references: fn is arbitrary + // backend code, and a nested GetOrCreate on this table would resize m_slots and + // invalidate both the iterator and any reference into the vector that outlives the + // call. The one caller today happens not to insert; that is not a property the + // walk should depend on. + for (SizeT index = 0; index < m_slots.size(); ++index) { + const Entry& entry = m_slots[index]; + if (!entry.Live || !entry.backend) continue; + const BackendPtr twin = entry.backend; + fn(MG_Pipe::MGPipeHandle{static_cast(index), entry.Gen}, twin); + } + for (SizeT index = 0; index < m_band.size(); ++index) { + const Entry& entry = m_band[index]; + if (!entry.Live || !entry.backend) continue; + const BackendPtr twin = entry.backend; + fn(MG_Pipe::MGPipeHandle{ + static_cast(index) + MG_Pipe::kMGPipeShaderCsoCompositeSlotBase, + entry.Gen}, + twin); + } + } + + Uint32 LiveCount() const { + Uint32 count = 0; + for (const Entry& entry : m_slots) { + if (entry.Live) ++count; + } + for (const Entry& entry : m_band) { + if (entry.Live) ++count; + } + return count; + } + + // The band's own live count, so a case can tell "the composite is twinned" from "the + // ordinary table grew to reach it" - which is the whole assertion the band exists for + // and is untestable from LiveCount alone. + Uint32 CompositeLiveCount() const { + Uint32 count = 0; + for (const Entry& entry : m_band) { + if (entry.Live) ++count; + } + return count; + } + + // How many entries each space has ALLOCATED, live or not. A leak case asserts on these + // rather than on LiveCount: growth is what the band prevents, and a dense table that + // never shrinks is invisible to a liveness count. + SizeT OrdinaryCapacityForTest() const { return m_slots.size(); } + SizeT CompositeCapacityForTest() const { return m_band.size(); } + + private: + // Drop the twin at `handle` if THIS table holds it. Frees nothing: the slot belongs to + // the kind, not to the table, and OnFrontendObjectDestroyed returns it once, after + // every holder has let go. + Bool ReleaseTwinAt(MG_Pipe::MGPipeHandle handle) { + // Forget the memo whenever it names this slot, even if this table has no entry + // there: a memo can be a handle learned from the allocator for an object another + // holder twinned, and it must not survive the slot's next handout. + if (m_memoHandle.Slot == handle.Slot) ForgetHandle(); + // The twin's destructor is a driver call and could, in principle, re-enter + // GetOrCreate on this table and resize m_slots. So NOTHING that outlives the + // destructor may be a reference into m_slots: the twin is moved out into a local, + // the entry is finished with, and only then is the local released. + BackendPtr dead; + { + Entry* const entry = EntryOrNull(handle.Slot); + if (entry == nullptr || !entry->Live || entry->Gen != handle.Gen) return false; + dead = std::move(entry->backend); + entry->backend.reset(); + entry->stateRef.reset(); + entry->Live = false; + } + dead.reset(); + return true; + } + + // ---- P5e (id): the two slot spaces, resolved in ONE place --------------------------- + // + // Every reader and writer below goes through these three, so a caller that forgot the + // band cannot exist - the shape MGPipeSlotAllocator::EntryOf already uses one level + // out. kHasCompositeBand folds to `false` at compile time for the five non-ShaderCso + // instantiations, so their band is dead code and an empty Vector. + static constexpr Bool SlotIsBanded(Uint32 slot) { + return kHasCompositeBand && MG_Pipe::MGPipeIsCompositeShaderSlot(slot); + } + static constexpr SizeT IndexOfSlot(Uint32 slot) { + return SlotIsBanded(slot) + ? static_cast(slot - MG_Pipe::kMGPipeShaderCsoCompositeSlotBase) + : static_cast(slot); + } + + // The entry a slot names, or null when its space has never grown that far. NEVER grows: + // a lookup that resized would turn every miss into an allocation, which is the hazard + // Find documents. + Entry* EntryOrNull(Uint32 slot) { + Vector& table = SlotIsBanded(slot) ? m_band : m_slots; + const SizeT index = IndexOfSlot(slot); + if (index >= table.size()) return nullptr; + return &table[index]; + } + const Entry* EntryOrNull(Uint32 slot) const { + return const_cast(this)->EntryOrNull(slot); + } + + // Grows the right space to hold `slot`. Every caller bounds `slot` first - the minting + // overload because the allocator produced it, the handle overload against + // kMaxHandleSlot - because this is the one place a client-supplied number decides an + // allocation size. A composite slot grows m_band by (slot - base) + 1, so the ordinary + // table never learns the band exists. + Entry& EntryAt(Uint32 slot) { + Vector& table = SlotIsBanded(slot) ? m_band : m_slots; + const SizeT index = IndexOfSlot(slot); + if (index >= table.size()) table.resize(index + 1); + return table[index]; + } + + void RememberHandle(Uint64 lifetimeId, MG_Pipe::MGPipeHandle handle) const { + m_memoLifetimeId = lifetimeId; + m_memoHandle = handle; + } + void ForgetHandle() const { + m_memoLifetimeId = 0; + m_memoHandle = MG_Pipe::kMGPipeNullHandle; + } + + // The holder list: intrusive and doubly linked, so registering and unregistering are + // two pointer writes with no allocation, and its head is a constant-initialised + // static - which is what lets the process-lifetime registry globals in Managers.cpp + // link themselves in from their own constructors with no initialisation-order + // question to answer. Single-threaded, like every table it links (the tables live and + // die on the context thread, as the notice they answer does). + void LinkHolder() { + m_prevHolder = nullptr; + m_nextHolder = s_firstHolder; + if (s_firstHolder != nullptr) s_firstHolder->m_prevHolder = this; + s_firstHolder = this; + } + void UnlinkHolder() { + if (m_prevHolder != nullptr) { + m_prevHolder->m_nextHolder = m_nextHolder; + } else { + s_firstHolder = m_nextHolder; + } + if (m_nextHolder != nullptr) m_nextHolder->m_prevHolder = m_prevHolder; + m_prevHolder = nullptr; + m_nextHolder = nullptr; + } + + static inline BackendSlotTable* s_firstHolder = nullptr; + BackendSlotTable* m_prevHolder = nullptr; + BackendSlotTable* m_nextHolder = nullptr; + + // Indexed by MGPipeHandle::Slot; [0] is the reserved slot and is never live. + Vector m_slots; + // P5e (id): the ShaderCso COMPOSITE band, indexed by (slot - + // kMGPipeShaderCsoCompositeSlotBase) and EMPTY for every other kind. See + // kHasCompositeBand above for why it is a second vector and not more of the first. + Vector m_band; + // Handed back by GetOrCreate for a null state object. Never live, never handed a handle. + BackendPtr m_nullTwin; + + // ONE-entry resolution memo, lifetimeId -> handle. It exists because without it every + // resolution goes through the allocator's ByLifetimeId hash, which the deleted + // TwinLookupMemos existed to avoid and which D13 promises to replace with "direct slot + // indexing". + // + // It is one entry and therefore only helps a caller that asks for the SAME object twice + // running - ResolveVaoTwin and SyncCurrentProgram do, once per draw each. Two callers + // it does NOT help, recorded rather than claimed away: BindCurrentFBO resolves BOTH + // targets in a frame, and ResolveUnitSamplerBackend asks for a different sampler per + // texture unit, so both thrash a single-entry memo and pay the probe P1 did not (P1 had + // a per-unit memo and a direct-mapped 6-slot array there). Making the memo per-unit / + // per-target is the fix, and G11 - the device-side gate that would price it - is owed. + // + // It cannot serve a stale answer, by three independent arguments: + // * the key is a lifetime id, which MG_State never hands out twice, so a recycled + // heap address cannot hit this memo the way it could hit an address-keyed one; + // * a null answer is never stored, so another holder's acquire cannot be hidden by + // a "no handle" this table remembered earlier; and + // * even a hit for a slot that has since been freed and re-handed is caught, because + // the caller resolves the handle through FindByHandle, which compares Gen. + // Cleared anyway when a death notice names the memoised slot. 0 is never a live + // lifetime id (MG_State's counters start at 1), so a zeroed memo is a guaranteed miss. + mutable Uint64 m_memoLifetimeId = 0; + mutable MG_Pipe::MGPipeHandle m_memoHandle = MG_Pipe::kMGPipeNullHandle; + }; + +#endif // MOBILEGL_PIPE_PUSH +} // namespace MobileGL::MG_Backend::DirectGLES diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.cpp b/MobileGL/MG_Backend/DirectGLES/Utils.cpp index 4239930f1..5ad96c6c6 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.cpp +++ b/MobileGL/MG_Backend/DirectGLES/Utils.cpp @@ -10,6 +10,9 @@ #include "Utils.h" #include "Managers.h" #include "MG_Backend/BackendObjects.h" +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#endif #include "MG_Util/Converters/GLToMG/FramebufferEnumConverter.h" #include "MG_Util/SelfTest/DriverBugProbes.h" #include "MG_Util/Texture/TextureFormatProcessor.h" @@ -17,6 +20,7 @@ #include #include +#include #include #include #include @@ -27,11 +31,36 @@ #include #include #include +#include #include #include #include namespace MobileGL::MG_Backend::DirectGLES { +#if MOBILEGL_BUILD_DISAGGREGATED + const FormatCapabilityCache* ActiveBackendFormatCaps() { + // Under a live split session the SERVER's private backend is what owns the context on the + // apply thread (and these reads all run there), so its cache is the authoritative one. + // Before the session lands, or under monolith transport in a disaggregated build, fall + // back to the process global exactly as the monolith path always has. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + if (MG_Backend::BackendObject* server = MG_Remote::Server::ServerLoopInstance().Backend()) { + return &server->GetFormatCapabilities(); + } + // P5c (hd, CONTRACT-P5C §3.7): the mirror fallback is REFUSED with an active + // transport. The server's backend exists whenever the session does, so reaching + // here is a bring-up ordering defect, and a silent read of the client caps mirror + // (pActiveBackendObject is client memory, rule E) would hide it. + MGLOG_F("MGPipe: Fatal{RoleViolation, \"caps-mirror\"} - the format-capability " + "fallback to the client caps mirror was reached with an active transport " + "but no server backend; the server's own backend is the only legal source " + "on the apply thread"); + std::abort(); + } + return pActiveBackendObject ? &pActiveBackendObject->GetFormatCapabilities() : nullptr; + } +#endif + namespace { Flags GetForcedPixelFormatNormalizeOptions() { Flags options; @@ -70,15 +99,26 @@ namespace MobileGL::MG_Backend::DirectGLES { SizeT targetIndex, Bool caveat, FormatCapability capability) { +#if MOBILEGL_BUILD_DISAGGREGATED + const FormatCapabilityCache* activeCaps = ActiveBackendFormatCaps(); + if (activeCaps == nullptr || targetIndex >= kFormatCapabilityTargetCount) { + return false; + } +#else if (!pActiveBackendObject || targetIndex >= kFormatCapabilityTargetCount) { return false; } +#endif const SizeT formatIndex = static_cast(internalFormat); if (formatIndex >= kFormatCapabilityFormatCount) { return false; } +#if MOBILEGL_BUILD_DISAGGREGATED + const FormatCapabilityCache& cache = *activeCaps; +#else const FormatCapabilityCache& cache = pActiveBackendObject->GetFormatCapabilities(); +#endif const FormatCapabilityFlags caps = caveat ? cache.CaveatCaps[targetIndex][formatIndex] : cache.FullCaps[targetIndex][formatIndex]; return HasFormatCapability(caps, capability); @@ -122,7 +162,11 @@ namespace MobileGL::MG_Backend::DirectGLES { using namespace MobileGL::MG_Util::TextureFormatProcessor; const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); Flags options; +#if MOBILEGL_BUILD_DISAGGREGATED + if (ActiveBackendFormatCaps() == nullptr || ShouldUseCaveatFormat(internalFormat, targetIndex)) { +#else if (!pActiveBackendObject || ShouldUseCaveatFormat(internalFormat, targetIndex)) { +#endif options = GetRuntimeFallbackNormalizeOptions( requestedInternalFormat, TextureImpl::GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex)); @@ -216,9 +260,15 @@ namespace MobileGL::MG_Backend::DirectGLES { // not be resolved yet - a probe run then would latch "cannot tell" as "clean" // forever. Once the backend exists, the first narrow-format image this process // creates runs the probe on a live context. +#if MOBILEGL_BUILD_DISAGGREGATED + if (ActiveBackendFormatCaps() == nullptr) { + return false; + } +#else if (pActiveBackendObject == nullptr) { return false; } +#endif return MG_Util::SelfTest::CopyImageMirrorsPacked16FieldOrder(g_GLESFuncs); } @@ -256,9 +306,15 @@ namespace MobileGL::MG_Backend::DirectGLES { if (!TargetRequiresRenderableFormat(targetIndex)) { return false; } +#if MOBILEGL_BUILD_DISAGGREGATED + if (ActiveBackendFormatCaps() != nullptr && !ShouldUseCaveatFormat(internalFormat, targetIndex)) { + return false; + } +#else if (pActiveBackendObject && !ShouldUseCaveatFormat(internalFormat, targetIndex)) { return false; } +#endif const GLenum requestedInternalFormat = MG_Util::ConvertTextureInternalFormatToGLEnum(internalFormat); const Flags options = GetRuntimeFallbackNormalizeOptions( requestedInternalFormat, GetRenderTargetNormalizeOptions(g_GLESCapabilities, targetIndex)); @@ -2294,11 +2350,11 @@ namespace MobileGL::MG_Backend::DirectGLES { static Bool StoreClientRows(SizeT dstPixelBytes, SizeT swapGroupSize, GLsizei width, GLsizei sliceHeight, GLsizei sliceCount, void* pixels, Bool applyPackImageParams, FillRow&& fillRow) { const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); // Destination layout is computed from the client-side PACK parameters; only the actual pixel // rows are written so skip regions of the destination stay untouched. - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : width); const SizeT dstRowStride = AlignReadbackRow(rowPixels * dstPixelBytes, packParams.Alignment); const SizeT imageRows = diff --git a/MobileGL/MG_Backend/DirectGLES/Utils.h b/MobileGL/MG_Backend/DirectGLES/Utils.h index ae7f4af00..ffedd745f 100644 --- a/MobileGL/MG_Backend/DirectGLES/Utils.h +++ b/MobileGL/MG_Backend/DirectGLES/Utils.h @@ -13,6 +13,19 @@ #include namespace MobileGL::MG_Backend::DirectGLES { +#if MOBILEGL_BUILD_DISAGGREGATED + // C6 / ID-52 / CONTRACT-P5 table 3's pActiveBackendObject row. The format-capability cache of + // THIS ROLE's backend. Under an active transport the server's own private + // BackendObject_DirectGLES owns the context on the apply thread, so a backend-internal format + // lookup must read ITS probed cache - not pActiveBackendObject's, which under split is the + // CLIENT's BackendObject_Remote mirror (a caps snapshot, generation-lagged, and on an + // independent server not usable at all). Monolith build/transport: pActiveBackendObject, so a + // pull build never sees this symbol. Null when no backend is up. The seven table-3 reads + // (five in Utils.cpp, ClampSamplesToBackendSupport in BackendObject_DirectGLES.cpp) go through + // this instead of dereferencing pActiveBackendObject directly. + const FormatCapabilityCache* ActiveBackendFormatCaps(); +#endif + namespace DebugImpl { class ErrorLopper { public: diff --git a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp index e8ce9d1cd..450cb48c0 100644 --- a/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/BackendObject_DirectVulkan.cpp @@ -12,6 +12,7 @@ #include "SubgroupSupportPolicy.h" #include "MG_State/GLState/FramebufferState/FramebufferObject.h" #include "MG_State/GLState/Core.h" +#include #include "MG_State/GLState/TextureState/TextureState.h" #include "MG_Util/Classifiers/TextureEnumClassifier.h" #include "MG_Util/Converters/MGToGL/TextureEnumConverter.h" @@ -385,8 +386,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } UpdateDynamicBackendParameters(); UpdateAdvertisedExtensions(); - if (MG_State::pGLContext) { - MG_State::pGLContext->InvalidateCompileEnv(); + if (MGB_CTX_LIVE) { + MGB_CTX->InvalidateCompileEnv(); } PopulateFormatCapabilities(physicalDevice.handle, vkGetPhysicalDeviceFormatProperties, m_vulkanCaps, MutableFormatCapabilities()); @@ -740,8 +741,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { funcsTable.GL.MemoryBarrierByRegion = MemoryBarrierByRegion; funcsTable.GL.BindImageTexture = BindImageTexture; funcsTable.GL.GetIntegeri_v = GetIntegeri_v; - funcsTable.GL.GetInteger64i_v = GetInteger64i_v; - funcsTable.GL.GetProgramiv = GetProgramiv; funcsTable.GL.ShaderStorageBlockBinding = ShaderStorageBlockBinding; funcsTable.GL.FenceSync = FenceSync; funcsTable.GL.ClientWaitSync = ClientWaitSync; @@ -785,8 +784,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { m_vulkanCaps = capabilities; UpdateDynamicBackendParameters(); UpdateAdvertisedExtensions(); - if (MG_State::pGLContext) { - MG_State::pGLContext->InvalidateCompileEnv(); + if (MGB_CTX_LIVE) { + MGB_CTX->InvalidateCompileEnv(); } MutableFormatCapabilities().Clear(); } @@ -939,6 +938,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { clampLimit("GL_MAX_COMPUTE_UNIFORM_BLOCKS", m_vulkanCaps.MaxComputeUniformBlocks, kMaxAdvertisedBufferBlocks); m_dynamicParameters.MaxComputeWorkGroupInvocations = m_vulkanCaps.MaxComputeWorkGroupInvocations; + // The six per-axis compute limits, from the same VkPhysicalDeviceLimits fields + // GLFunctionsTable::GetIntegeri_v (DirectVulkan.cpp) reads live. Carried here so that + // MGPCaps has them once the table entry retires (plan B section 4.4.1); GL_Getter floors + // them. Not clamped: unlike the block counts these are not amounts an application + // allocates, and the frontend already raises them to the GL minimum. + for (SizeT axis = 0; axis < 3; ++axis) { + m_dynamicParameters.MaxComputeWorkGroupCount[axis] = m_vulkanCaps.MaxComputeWorkGroupCount[axis]; + m_dynamicParameters.MaxComputeWorkGroupSize[axis] = m_vulkanCaps.MaxComputeWorkGroupSize[axis]; + } m_dynamicParameters.MaxShaderStorageBufferBindings = clampLimit("GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS", m_vulkanCaps.MaxShaderStorageBufferBindings, kMaxAdvertisedBufferBlocks); diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp index f135a4aad..039118795 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp @@ -1,4 +1,4 @@ -// MobileGL - MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp +// MobileGL - MobileGL/MG_Backend/DirectVulkan/DirectVulkan.cpp // Copyright (c) 2025-2026 MobileGL-Dev // Licensed under the GNU Lesser General Public License v3.0: // https://www.gnu.org/licenses/gpl-3.0.txt @@ -10,11 +10,17 @@ #include "DirectVulkanResourceState.h" #include "MG_Backend/BackendObjects.h" #include "MG_State/GLState/Core.h" +#include #include "MG_State/GLState/ErrorState/ErrorInfo.h" #include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h" #include "MG_Util/Converters/GLToMG/TextureEnumConverter.h" +#include "MG_Util/Metrics/PipeStats.h" #include "MG_Util/Metrics/TextureMetrics.h" #include "MG_Util/Miscellany/IndexGenerator.h" +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#endif #include #include #include @@ -77,7 +83,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint32 blockBindingVersion = 0; Vector storageBlocks; Vector bufferVariables; - GLint computeWorkGroupSize[3] = {1, 1, 1}; }; struct DrawElementsIndirectCommand { @@ -208,16 +213,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { } for (auto& module : modules) { - for (Uint32 entryIndex = 0; entryIndex < module.entry_point_count; ++entryIndex) { - const auto& entryPoint = module.entry_points[entryIndex]; - if ((entryPoint.shader_stage & SPV_REFLECT_SHADER_STAGE_COMPUTE_BIT) == 0) { - continue; - } - cache.computeWorkGroupSize[0] = static_cast(std::max(entryPoint.local_size.x, 1)); - cache.computeWorkGroupSize[1] = static_cast(std::max(entryPoint.local_size.y, 1)); - cache.computeWorkGroupSize[2] = static_cast(std::max(entryPoint.local_size.z, 1)); - } - uint32_t bindingCount = 0; SpvReflectResult result = spvReflectEnumerateDescriptorBindings(&module, &bindingCount, nullptr); if (result != SPV_REFLECT_RESULT_SUCCESS || bindingCount == 0) { @@ -277,15 +272,15 @@ namespace MobileGL::MG_Backend::DirectVulkan { } MG_State::GLState::ProgramObject* TryGetDirectVulkanProgram(GLuint program) { - if (!MG_State::pGLContext->ValidateProgramName(program)) { + if (!MGB_CTX->ValidateProgramName(program)) { return nullptr; } - auto& programObject = MG_State::pGLContext->GetProgramObject(program); + auto& programObject = MGB_CTX->GetProgramObject(program); return programObject.get(); } const Uint8* ResolveIndirectCommandBytes(const void* indirect, SizeT requiredBytes, const char* label) { - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { drawBuffer->SyncPersistentMappedRange(); const SizeT commandOffset = reinterpret_cast(indirect); @@ -344,64 +339,64 @@ namespace MobileGL::MG_Backend::DirectVulkan { void ClearBufferfi(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferfi called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferfi called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferfi called with null GL context"); pVulkanRenderer->ClearBufferfi(buffer, drawbuffer, depth, stencil); } void ClearBufferfv(GLenum buffer, GLint drawbuffer, const GLfloat* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferfv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferfv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferfv called with null GL context"); pVulkanRenderer->ClearBufferfv(buffer, drawbuffer, value); } void ClearBufferuiv(GLenum buffer, GLint drawbuffer, const GLuint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferuiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferuiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferuiv called with null GL context"); pVulkanRenderer->ClearBufferuiv(buffer, drawbuffer, value); } void ClearBufferiv(GLenum buffer, GLint drawbuffer, const GLint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearBufferiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearBufferiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearBufferiv called with null GL context"); pVulkanRenderer->ClearBufferiv(buffer, drawbuffer, value); } void ClearNamedFramebufferfv(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, const GLfloat* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferfv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferfv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferfv called with null GL context"); pVulkanRenderer->ClearNamedFramebufferfv(framebuffer, buffer, drawbuffer, value); } void ClearNamedFramebufferiv(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, const GLint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferiv called with null GL context"); pVulkanRenderer->ClearNamedFramebufferiv(framebuffer, buffer, drawbuffer, value); } void ClearNamedFramebufferuiv(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, const GLuint* value) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferuiv called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferuiv called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferuiv called with null GL context"); pVulkanRenderer->ClearNamedFramebufferuiv(framebuffer, buffer, drawbuffer, value); } void ClearNamedFramebufferfi(const SharedPtr& framebuffer, GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ClearNamedFramebufferfi called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ClearNamedFramebufferfi called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ClearNamedFramebufferfi called with null GL context"); pVulkanRenderer->ClearNamedFramebufferfi(framebuffer, buffer, drawbuffer, depth, stencil); } void MultiDrawElementsIndirect(GLenum mode, GLenum type, const void* indirect, GLsizei drawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElementsIndirect called with null GL context"); pVulkanRenderer->MultiDrawElementsIndirect(mode, type, indirect, drawcount, stride); } void MultiDrawArraysIndirect(GLenum mode, const void* indirect, GLsizei drawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawArraysIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawArraysIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawArraysIndirect called with null GL context"); if (drawcount <= 0) { return; @@ -409,7 +404,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // With a bound GL_DRAW_INDIRECT_BUFFER the command parameters may be GPU-written // (e.g. by a compute shader), so consume them natively on the GPU. - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { pVulkanRenderer->MultiDrawArraysIndirect(mode, indirect, drawcount, stride); return; @@ -452,13 +447,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawElementsIndirectCount(GLenum mode, GLenum type, const void* indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsIndirectCount called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsIndirectCount called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElementsIndirectCount called with null GL context"); pVulkanRenderer->MultiDrawElementsIndirectCount(mode, type, indirect, drawcount, maxdrawcount, stride); } void MultiDrawArraysIndirectCount(GLenum mode, const void* indirect, GLintptr drawcount, GLsizei maxdrawcount, GLsizei stride) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawArraysIndirectCount called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawArraysIndirectCount called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawArraysIndirectCount called with null GL context"); if (maxdrawcount <= 0) { return; @@ -472,7 +467,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return; } - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!parameterBuffer || drawcount < 0 || static_cast(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawArraysIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range"); return; @@ -503,7 +498,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawElementsInstancedBaseVertexBaseInstance(GLenum mode, GLsizei count, GLenum type, const void* indices, GLsizei instancecount, GLint basevertex, GLuint baseinstance) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElementsInstancedBaseVertexBaseInstance called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElementsInstancedBaseVertexBaseInstance called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElementsInstancedBaseVertexBaseInstance called with null GL context"); DrawIndexedCmd payload{}; payload.mode = mode; @@ -530,7 +525,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } void DrawElementsIndirect(GLenum mode, GLenum type, const void* indirect) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElementsIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElementsIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElementsIndirect called with null GL context"); const SizeT indexSize = MG_Util::GetGLTypeSize(type); if (indexSize == 0) { @@ -540,7 +535,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // With a bound GL_DRAW_INDIRECT_BUFFER the command parameters may be GPU-written // (e.g. by a compute shader), so consume them natively on the GPU. - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { pVulkanRenderer->MultiDrawElementsIndirect(mode, type, indirect, 1, 0); return; @@ -574,7 +569,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawArraysInstancedBaseInstance(GLenum mode, GLint first, GLsizei count, GLsizei instancecount, GLuint baseinstance) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawArraysInstancedBaseInstance called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawArraysInstancedBaseInstance called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawArraysInstancedBaseInstance called with null GL context"); DrawCmd payload{}; payload.mode = mode; @@ -589,11 +584,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { } void DrawArraysIndirect(GLenum mode, const void* indirect) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawArraysIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawArraysIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawArraysIndirect called with null GL context"); // With a bound GL_DRAW_INDIRECT_BUFFER the command parameters may be GPU-written // (e.g. by a compute shader), so consume them natively on the GPU. - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (drawBuffer) { pVulkanRenderer->MultiDrawArraysIndirect(mode, indirect, 1, 0); return; @@ -623,13 +618,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void CopyTexImage2D(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLsizei height, GLint border) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::CopyTexImage2D called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::CopyTexImage2D called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::CopyTexImage2D called with null GL context"); pVulkanRenderer->CopyTexSubImage2D(target, level, 0, 0, x, y, width, height); } void CopyTexSubImage2D(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::CopyTexSubImage2D called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::CopyTexSubImage2D called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::CopyTexSubImage2D called with null GL context"); pVulkanRenderer->CopyTexSubImage2D(target, level, xoffset, yoffset, x, y, width, height); } void CopyImageSubData(const CopyImageEndpoint& src, @@ -638,32 +633,32 @@ namespace MobileGL::MG_Backend::DirectVulkan { GLenum dstTarget, GLint dstLevel, GLint dstX, GLint dstY, GLint dstZ, GLsizei srcWidth, GLsizei srcHeight, GLsizei srcDepth) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::CopyImageSubData called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::CopyImageSubData called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::CopyImageSubData called with null GL context"); pVulkanRenderer->CopyImageSubData(src, srcTarget, srcLevel, srcX, srcY, srcZ, dst, dstTarget, dstLevel, dstX, dstY, dstZ, srcWidth, srcHeight, srcDepth); } void GenerateMipmap(GLenum target) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GenerateMipmap called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::GenerateMipmap called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::GenerateMipmap called with null GL context"); pVulkanRenderer->GenerateMipmap(target); } void DispatchCompute(GLuint numGroupsX, GLuint numGroupsY, GLuint numGroupsZ) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DispatchCompute called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DispatchCompute called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DispatchCompute called with null GL context"); pVulkanRenderer->DispatchCompute(numGroupsX, numGroupsY, numGroupsZ); } void DispatchComputeIndirect(GLintptr indirect) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DispatchComputeIndirect called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DispatchComputeIndirect called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DispatchComputeIndirect called with null GL context"); pVulkanRenderer->DispatchComputeIndirect(indirect); } void MemoryBarrier(GLbitfield barriers) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MemoryBarrier called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MemoryBarrier called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MemoryBarrier called with null GL context"); pVulkanRenderer->MemoryBarrier(barriers); } @@ -682,138 +677,62 @@ namespace MobileGL::MG_Backend::DirectVulkan { (void)format; } + // The two compute limits are the only indexed pnames a backend genuinely owns: they come + // from the physical device, and MG_Impl/GLImpl/Getter/GL_Getter.cpp asks for them here so it + // can raise the answer to the GL required minimum. The same six numbers are carried in + // DynamicBackendParameters::MaxComputeWorkGroupCount/Size (filled at capability init from + // the same limits), which is their MGPCaps carrier once this entry retires - the + // AdvertisedLimitsScenario pins the two against each other. Every other indexed pname names FRONTEND + // state (the indexed buffer bindings, the per-unit texture/sampler bindings, the image-unit + // bindings, the viewport rectangles, the indexed capabilities) and is answered there before + // the table is consulted, so the arms this function used to carry for + // GL_SHADER_STORAGE_BUFFER_* and GL_IMAGE_BINDING_* were unreachable duplicates - and not + // even faithful ones: the frontend reports the range glBindBufferRange was ASKED for, + // verbatim, while these clamped it to the buffer's current storage. void GetIntegeri_v(GLenum target, GLuint index, GLint* data) { if (!data) return; MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GetIntegeri_v called with null VulkanRenderer"); + if (index >= 3) { + *data = 0; + return; + } switch (target) { case GL_MAX_COMPUTE_WORK_GROUP_COUNT: - if (index >= 3) { - *data = 0; - return; - } *data = static_cast( pVulkanRenderer->GetPhysicalDevice().properties.limits.maxComputeWorkGroupCount[index]); return; case GL_MAX_COMPUTE_WORK_GROUP_SIZE: - if (index >= 3) { - *data = 0; - return; - } *data = static_cast( pVulkanRenderer->GetPhysicalDevice().properties.limits.maxComputeWorkGroupSize[index]); return; - case GL_SHADER_STORAGE_BUFFER_BINDING: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - *data = obj ? static_cast(obj->GetExternalIndex()) : 0; - return; - } - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } - case GL_IMAGE_BINDING_NAME: - case GL_IMAGE_BINDING_LEVEL: - case GL_IMAGE_BINDING_LAYERED: - case GL_IMAGE_BINDING_LAYER: - case GL_IMAGE_BINDING_ACCESS: - case GL_IMAGE_BINDING_FORMAT: { - if (index >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { - *data = 0; - return; - } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(static_cast(index)); - if (target == GL_IMAGE_BINDING_NAME) { - *data = imageBinding.Texture ? static_cast(imageBinding.Texture->GetExternalIndex()) : 0; - } else if (target == GL_IMAGE_BINDING_LEVEL) { - *data = imageBinding.Level; - } else if (target == GL_IMAGE_BINDING_LAYERED) { - *data = imageBinding.Layered; - } else if (target == GL_IMAGE_BINDING_LAYER) { - *data = imageBinding.Layer; - } else if (target == GL_IMAGE_BINDING_ACCESS) { - *data = static_cast(imageBinding.Access); - } else { - *data = static_cast(imageBinding.Format); - } - return; - } default: *data = 0; return; } } - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data) { - if (!data) return; - switch (target) { - case GL_SHADER_STORAGE_BUFFER_START: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - *data = static_cast(point.GetRange().start); - return; - } - case GL_SHADER_STORAGE_BUFFER_SIZE: { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index); - auto& obj = point.GetBoundObject(); - if (!obj) { - *data = 0; - return; - } - const auto& range = point.GetRange(); - const auto start = std::min(range.start, obj->GetSize()); - const auto end = std::min(range.end, obj->GetSize()); - *data = static_cast(end - start); - return; - } - default: - *data = 0; - return; - } - } - - void GetProgramiv(GLuint program, GLenum pname, GLint* params) { - if (!params) return; - auto* programObject = TryGetDirectVulkanProgram(program); - if (!programObject) { - params[0] = 0; - return; - } - switch (pname) { - case GL_COMPUTE_WORK_GROUP_SIZE: { - auto& cache = GetProgramResourceCache(*programObject); - params[0] = cache.computeWorkGroupSize[0]; - params[1] = cache.computeWorkGroupSize[1]; - params[2] = cache.computeWorkGroupSize[2]; - return; - } - default: - params[0] = 0; - return; - } - } - void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding) { auto* programObject = TryGetDirectVulkanProgram(program); if (!programObject || storageBlockName == nullptr) return; - const Int maxBindings = pActiveBackendObject - ? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings - : 0; + const Int maxBindings = +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.7): with an active transport the dynamic parameters are + // the SERVER's own backend's - the client caps mirror (pActiveBackendObject) is + // client memory the apply thread may not name (rule E). Monolith reads the mirror + // as it always did. + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? (MG_Remote::Server::ServerLoopInstance().Backend() != nullptr + ? static_cast(MG_Remote::Server::ServerLoopInstance().Backend() + ->GetDynamicParameters() + .MaxShaderStorageBufferBindings) + : 0) + : +#endif + pActiveBackendObject + ? pActiveBackendObject->GetDynamicParameters().MaxShaderStorageBufferBindings + : 0; if (storageBlockBinding >= static_cast(maxBindings)) { - MG_State::pGLContext->RecordError( + MGB_CTX->RecordError( ErrorCode::InvalidValue, MakeUnique("DirectVulkan", __func__, "Shader storage binding is out of range.")); return; @@ -838,24 +757,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { } void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::ReadPixels called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::ReadPixels called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::ReadPixels called with null GL context"); pVulkanRenderer->ReadPixels(x, y, width, height, format, type, pixels); } void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GetTexImage called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::GetTexImage called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::GetTexImage called with null GL context"); pVulkanRenderer->GetTexImage(target, level, format, type, pixels); } void GetTextureImage(const SharedPtr& texture, TextureUploadTarget uploadTarget, GLint level, GLenum format, GLenum type, GLsizei bufSize, GLvoid* pixels) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::GetTextureImage called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::GetTextureImage called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::GetTextureImage called with null GL context"); pVulkanRenderer->GetTextureImage(texture, uploadTarget, level, format, type, bufSize, pixels); } void Clear(GLbitfield mask) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::Clear called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::Clear called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::Clear called with null GL context"); pVulkanRenderer->Clear(mask); } @@ -883,7 +802,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } const Uint8* indexBytes = nullptr; - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); const auto& indexBufferShared = vao.GetIndexBufferBindingSlot().GetBoundObject(); if (indexBufferShared != nullptr) { const SizeT offset = reinterpret_cast(indices); @@ -914,7 +833,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawArrays(GLenum mode, GLint first, GLsizei count) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawArrays called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawArrays called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawArrays called with null GL context"); if (mode == GL_LINE_LOOP) { if (count < 2) { @@ -939,7 +858,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void DrawElements(GLenum mode, GLsizei count, GLenum type, const void* indices) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElements called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElements called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElements called with null GL context"); if (mode == GL_LINE_LOOP) { Vector closedIndices; @@ -962,7 +881,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawArrays(GLenum mode, const GLint* first, const GLsizei* count, GLsizei drawcount) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawArrays called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawArrays called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawArrays called with null GL context"); if (drawcount <= 0) { return; } @@ -1007,7 +926,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // MultiDrawIndexedCmd left the client-memory shape addressing a view whose byte // offset is a hardcoded 0, so UploadAndBindIndexBuffer saw a null client pointer, // declined the whole batch and painted nothing.) - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); if (vao.GetIndexBufferBindingSlot().GetBoundObject() == nullptr) { for (GLsizei i = 0; i < drawcount; ++i) { if (count[i] <= 0) { @@ -1068,13 +987,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawElements(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices, GLsizei drawcount) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElements called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElements called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElements called with null GL context"); MultiDrawElementsImpl(mode, count, type, indices, drawcount, nullptr); } void DrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, const GLvoid* indices, GLint basevertex) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::DrawElementsBaseVertex called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::DrawElementsBaseVertex called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::DrawElementsBaseVertex called with null GL context"); if (mode == GL_LINE_LOOP) { Vector closedIndices; if (BuildClosedLineLoopIndices(count, type, indices, closedIndices)) { @@ -1098,14 +1017,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { void MultiDrawElementsBaseVertex(GLenum mode, const GLsizei* count, GLenum type, const GLvoid* const* indices, GLsizei drawcount, const GLint* basevertex) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::MultiDrawElementsBaseVertex called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::MultiDrawElementsBaseVertex called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::MultiDrawElementsBaseVertex called with null GL context"); MultiDrawElementsImpl(mode, count, type, indices, drawcount, basevertex); } void BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::BlitFramebuffer called with null VulkanRenderer"); - MOBILEGL_ASSERT(MG_State::pGLContext, "DirectVulkan::BlitFramebuffer called with null GL context"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "DirectVulkan::BlitFramebuffer called with null GL context"); pVulkanRenderer->BlitFramebuffer(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); } @@ -1333,8 +1252,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } if (query->kind == VulkanTimerQuery::Kind::XfbGenerated && - !query->pausedPrimitivesCountedByGpu && MG_State::pGLContext != nullptr) { - primitives += MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() - + !query->pausedPrimitivesCountedByGpu && MGB_CTX_LIVE) { + primitives += MGB_CTX->GetTransformFeedbackPausedPrimitiveCounter() - query->pausedPrimitiveSnapshot; } *outNanoseconds = primitives; @@ -1381,7 +1300,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { query->kind = generated ? VulkanTimerQuery::Kind::XfbGenerated : VulkanTimerQuery::Kind::XfbWritten; query->rendererGeneration = GetRendererGeneration(); query->pausedPrimitiveSnapshot = - MG_State::pGLContext ? MG_State::pGLContext->GetTransformFeedbackPausedPrimitiveCounter() : 0; + MGB_CTX_LIVE ? MGB_CTX->GetTransformFeedbackPausedPrimitiveCounter() : 0; // Read AFTER StartXfbQueryCapture, which is where a failed reroute-pool creation // disarms: the answer is then what this span will actually do for every draw. query->pausedPrimitivesCountedByGpu = generated && pVulkanRenderer->ArePausedDrawsGpuCounted(); @@ -1430,5 +1349,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { void Present() { MOBILEGL_ASSERT(pVulkanRenderer, "DirectVulkan::Present called with null VulkanRenderer"); pVulkanRenderer->Present(); + // THE frame boundary for the MGPipe counters, at the backend entry point rather + // than inside VulkanRenderer::Present: that function has an early return for the + // no-usable-swapchain case, and a suspended frame is still a frame the counters + // must close. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::OnPresent(); + } } } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h index 74241e810..cdf361412 100644 --- a/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h +++ b/MobileGL/MG_Backend/DirectVulkan/DirectVulkan.h @@ -95,8 +95,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { void BindImageTexture(GLuint unit, GLuint texture, GLint level, GLboolean layered, GLint layer, GLenum access, GLenum format); void GetIntegeri_v(GLenum target, GLuint index, GLint* data); - void GetInteger64i_v(GLenum target, GLuint index, GLint64* data); - void GetProgramiv(GLuint program, GLenum pname, GLint* params); void ShaderStorageBlockBinding(GLuint program, const GLchar* storageBlockName, GLuint storageBlockBinding); void ReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels); void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h new file mode 100644 index 000000000..37c76d25c --- /dev/null +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h @@ -0,0 +1,603 @@ +// MobileGL - MobileGL/MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include +#if MOBILEGL_PIPE_PUSH +// kMGPipeSubsystem* - the runtime bitmask's named bits - and MGPipeHandle itself. Both are +// header-only constant/POD declarations, and both are push-only, so the pull build's include +// graph is unchanged (G1). +#include +#include +#endif + +#include + +// Magma's arm selector for the P2 Track H / render-state re-keys (P2 brief D14), and the +// {slot, gen} mint the re-keyed sites are written against. +// +// Two switches decide which arm a re-keyed site runs, and they are NOT the same switch: +// +// MOBILEGL_PIPE_PUSH (compile) - is the pushed state there to be keyed on at all +// Features.PipePush (runtime bitmask) - is THIS subsystem migrated in THIS run +// MOBILEGL_PIPE_LEGACY_MEMOS (compile) - is the pre-handle arm compiled beside it +// Features.PipeLegacyMemos (runtime) - may the pre-handle arm be ENTERED in this run +// +// ARCHITECTURE.md 9.6's point: once a handle wave lands, a clear MOBILEGL_PIPE_PUSH bit is +// only a valid A/B while the legacy arm is still compiled, because with the bit clear the +// backend would otherwise still run the re-keyed code. So a clear bit selects the legacy +// arm, and a run that has explicitly disabled the legacy arm may not fall into it. +// +// D14 spends that last sentence at STARTUP, not per draw: "a Track-H subsystem whose bit is +// clear is a startup Fatal{PipeLegacyMemosDisabled}". Nothing in the draw path aborts, and +// nothing outside Track H consults the legacy-memo lever at all - see +// MagmaPipeValidateSubsystemConfiguration below for both halves of that rule. +// +// The whole header is inert in a pull build: MOBILEGL_PIPE_PUSH is 0 there, every helper +// below is behind it, and the pull build's translation units are byte-identical (G1). +namespace MobileGL::MG_Backend::DirectVulkan { + +#if MOBILEGL_PIPE_PUSH + // Is `subsystemBit` (MG_Pipe/MGPipe.h's kMGPipeSubsystem*) migrated in this run? + inline Bool MagmaPipeSubsystemOn(Uint64 subsystemBit) { + return (MG_Config::Features.PipePush & subsystemBit) != 0; + } + + // --------------------------------------------------------------------------------- + // D14's startup gate + // --------------------------------------------------------------------------------- + // + // Called once from VulkanRenderer::Initialize(), i.e. only when Magma is the backend + // that is actually running. It answers exactly one question and it answers it before the + // first draw: is there an arm for Magma's Track-H subsystem in this configuration? + // + // Three deliberate boundaries, each of which the per-draw shape this replaces got wrong: + // + // * ONLY Magma's own Track-H bit is checked. Espryt's bit 5 is Espryt's business (a + // DirectVulkan run does not execute one line of DirectGLES' re-key), so + // MOBILEGL_PIPE_PUSH=0x20 must not kill a Magma run, and MOBILEGL_PIPE_PUSH=0x40 must + // not kill an Espryt one. + // * bit 0 (kMGPipeSubsystemRenderState) is NOT Track H and is NOT fatal. It is not a + // memo re-key at all: it decides where the pipeline memo's STATE KEY comes from, and + // a clear bit there simply means the client is not pushing render-state CSOs in this + // run, which GetOrCreatePipeline answers with its own state hash. D14 labels bits 5 + // and 6 "Track H" and labels bit 0 nothing of the sort. + // * it is Fatal at STARTUP, once, not on a draw. A per-draw abort inside + // GetOrCreatePipeline turns a configuration mistake into a mid-frame crash and puts a + // branch nobody needs on the hottest path in the backend. + // + // [declared deviation from D14, review v2 minor 2] D14's runtime row reads "false: the + // legacy arm is never entered", and D14's compile-switch row names ComputePipelineStateHash + // as part of the pre-handle arm. Those two together would make MOBILEGL_PIPE_LEGACY_MEMOS=0 + // with bit 0 CLEAR a contradiction: the pipeline memo has no CSO handle to key on, so it + // keys on a state hash, and in a build that compiles the pre-handle arm that hash IS + // ComputePipelineStateHash. Magma does not make that fatal - bit 0 is not Track H, and + // there is a correct answer (the state hash) where for bits 5/6 there is none - but it no + // longer does it SILENTLY: the combination is named once, at startup, right here. + inline void MagmaPipeValidateSubsystemConfiguration() { + if (!MG_Config::Features.PipeLegacyMemos && + !MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) { + MGLOG_W("MGPipe: MOBILEGL_PIPE_LEGACY_MEMOS=0 with kMGPipeSubsystemRenderState (bit 0 " + "of MOBILEGL_PIPE_PUSH) clear - Magma's pipeline memo has no CSO handle to key " + "on, so every draw whose pipeline-state version moved runs the pre-handle STATE " + "HASH instead. That is not a Track-H subsystem and not fatal, but it is not the " + "handle arm either: set bit 0 (MOBILEGL_PIPE_PUSH=0x%llx) if this run was meant " + "to measure it.", + static_cast(MG_Config::Features.PipePush | + MG_Pipe::kMGPipeSubsystemRenderState)); + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + // The pre-handle arm is compiled AND the operator has not forbidden entering it, so a + // clear bit is an ordinary, valid A/B: the site takes the legacy arm. + if (MG_Config::Features.PipeLegacyMemos) return; +#endif + if (MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) return; +#if MOBILEGL_PIPE_LEGACY_MEMOS + const char* const why = "this run has MOBILEGL_PIPE_LEGACY_MEMOS=0"; +#else + const char* const why = + "this build has cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF, which compiles no such arm"; +#endif + MGLOG_F("MGPipe: Fatal{PipeLegacyMemosDisabled} Magma's Track-H subsystem " + "(kMGPipeSubsystemMagmaVertexInput, bit 6 of MOBILEGL_PIPE_PUSH) is clear, so the " + "vertex-input cache and the VAO draw memo want the pre-handle arm - but %s. Set " + "bit 6 (MOBILEGL_PIPE_PUSH=0x%llx, or the default 0x%llx), or allow the legacy arm.", + why, + static_cast(MG_Config::Features.PipePush | + MG_Pipe::kMGPipeSubsystemMagmaVertexInput), + static_cast(MG_Pipe::kMGPipeSubsystemsMigratedAtP2)); + std::abort(); + } + + // "Does this Track-H site run the handle arm?" - the ONE question every re-keyed Track-H + // site asks, so that they cannot disagree with each other or with the startup gate. + inline Bool MagmaPipeTrackHArmIsHandles(Uint64 trackHBit) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + return MagmaPipeSubsystemOn(trackHBit); +#else + // No pre-handle arm exists in this build, and MagmaPipeValidateSubsystemConfiguration + // has already made a clear bit a startup Fatal, so the handle arm is the only arm a + // running process can be on. + (void)trackHBit; + return true; +#endif + } + + // --------------------------------------------------------------------------------- + // Negative control C (P2 brief D18): MOBILEGL_PIPE_HANDLE_ABA_CONTROL + // --------------------------------------------------------------------------------- + // + // "Is the object-identity half of every vertex-input memo key deliberately defeated in + // this run?" - the ONE question the control's sites ask, for the same reason + // MagmaPipeTrackHArmIsHandles exists: three sites deciding separately could disagree, + // and a control that defeats two of three guards proves nothing. + // + // WHAT IT DEFEATS, AND WHY IT IS SPELLED AS "REPLACE THE IDENTITY WITH A CONSTANT" + // RATHER THAN "USE THE HEAP ADDRESS". + // + // D18 wrote the control as "hash attr.Buffer.get() instead of GetLifetimeId(), and skip + // the vaoLifetimeId compare", on the theory that a deleted object's replacement lands at + // the freed heap block and so reproduces the key. Measured, it does not: in + // HandleRecycleScenario the GL NAMES come back (glGen* hands the deleted name straight + // out) but the C++ heap blocks do not - a VertexArrayObject is 3920 bytes, too large for + // glibc's tcache, so its chunk goes to the unsorted bin and is split by the very next + // allocation the replacement path makes. Four create/delete cycles in one run produced + // four distinct addresses, ~1 MiB apart. With no address reuse there is nothing for + // "hash the address" to collide with: the replacement hashes differently, indexes a + // different memo slot, and inherits nothing - so the arm asserted stale pixels and saw + // fresh ones, which is a FAILING negative control that had stopped controlling anything. + // + // So the control no longer asks the allocator for the collision; it manufactures it. On + // both arms the object identity is replaced by a constant, which is the strongest form of + // "the allocator handed the block back" and is deterministic. That covers strictly more + // than D18's spelling, and in particular it reaches the arm P2 SHIPS: on the handle arm + // the constant defeats the OBJECT IDENTITY THAT SELECTS THE SLOT - the key the handle arm + // ships - so the replacement VAO is handed the dead one's memo entry and its content hash. + // Defeating only the retired lifetime-id/address guards would leave that key untested, + // which is exactly the vacuity this control exists to catch. + // + // WHAT IT DOES NOT COVER, AND WHY NO REPRODUCER OF THIS SHAPE CAN [fix-aba review v1, + // MAJOR 1]. It does NOT exercise the GENERATION half of {slot, gen}: + // + // * this mint has no death notification - nothing in MG_Backend/DirectVulkan consumes + // NotifyStateObjectDestroyed - so a slot returns to the free list only through + // OnFrameBoundary's age sweep (kSweepInterval 256, kRetireAgeBoundaries 1024, below); + // * HandleRecycleScenario issues five frame boundaries, so the free list is empty when + // the replacement VAO acquires and it gets a BRAND-NEW slot at Gen 1 (measured: + // redVao slot=2 gen=1, greenVao slot=3 gen=1). The knob-off FRESH verdict there is + // decided by the SLOT alone, and deleting the ++Gen below leaves all four arms green; + // * a genuine slot REUSE needs >= 1024 idle boundaries after the dead object's last + // draw, which necessarily puts the two draws in different frames - and the only memo + // that carries a GPU slice rather than a layout, ResolvedVertexBindings, declines + // across frames by design. The two requirements are mutually exclusive, so the + // generation is out of reach of any same-frame pixel reproducer for this memo. + // + // The generation is covered where it IS expressible, over this mint and the claim rule + // MagmaPipeClaimSlotMemos below: MG_Test/Pipe/MagmaPipeIdentityTest.cpp drives a real + // retire -> reuse and asserts that a memo stamped at {slot, gen=N} is not served at + // {slot, gen=N+1} with the knob off and IS served with it on. Deleting the ++Gen reds that + // suite; it is the only place in the tree where that deletion is caught. + // + // Everything the control does NOT defeat is as load-bearing as what it does. It never + // touches a guard that is not an IDENTITY guard: the resolved-bindings memo's frame + // serial, its slice-epoch compares and its host-map check all stay in force, so a green + // AbaControl arm still means "a replacement object was handed its dead predecessor's + // resolved vertex bindings because the identity halves of the keys were defeated", not + // "every safety net was switched off until something broke". + // + // Off by default (Config.h), set only by the HandleRecycle AbaControl ctest lanes, and + // #if MOBILEGL_PIPE_PUSH throughout, so no shipping pull build can even parse it. + // P4a (BRIEF-P4A.md D-I2, G8): WHICH KINDS THIS ANSWER COVERS, and it is not "all of them". + // + // P4a mints six more client-side kinds - Texture, Renderbuffer, Framebuffer, SamplerCso, + // SamplerViewCso and ShaderCso - and requires the ABA control to defeat "the identity half + // of P4a's memo keys as well", because a control that only defeats the guards a phase + // RETIRED says nothing about the key that phase SHIPS. + // + // On Magma there is no such key to defeat, and that is a fact about the roadmap rather than + // an omission here. MagmaPipeIdentityTables below mints exactly TWO kinds, + // VertexElementsCso and Buffer; a texture, a framebuffer, a sampler, a view and a program + // are all still reached from their frontend objects on this backend, and moving them onto + // handles is P7's work (ROADMAP.md:24 - "Magma anything"; P4a leaves MG_Backend/DirectVulkan + // untouched apart from this file). So the honest statement is per KIND, and it is spelled as + // code rather than as a comment so that a caller cannot read the blanket answer above and + // conclude the knob covers its kind: + // + // * for the two kinds this backend really keys on {slot, gen}, the knob defeats the + // identity exactly as it always has (MagmaPipeClaimSlotMemos); + // * for P4a's six there is nothing here to defeat, so the answer is FALSE - and + // MG_IntegrationTest's HandleRecycleScenario reads that through its own build probe and + // makes those cases' AbaControl arm assert the CORRECT pixels while SAYING that it is + // not controlling anything for that kind. It does not assert a corruption that no code + // on this tree can produce, which would be a permanently red always-on lane. + // + // WHAT MAKES IT TRUE LATER, in one sentence, so the next reader does not have to derive it: + // when a backend grows a Features.PipeHandleAbaControl consumer over its P4a object slot + // tables - one `if` in GetOrCreate / FindByHandle, the shape MagmaPipeClaimSlotMemos already + // has for vertex input - this function's per-kind answer becomes that consumer's, the + // integration probe finds the consumer, and the six cases flip to expecting the corruption. + inline Bool MagmaPipeAbaControlDefeatsIdentity() { + return MG_Config::Features.PipeHandleAbaControl; + } + + // WHICH KINDS THIS BACKEND ACTUALLY KEYS ON {slot, gen}, and therefore which kinds the knob + // above has an identity to defeat at all. `kind` is MG_Pipe::MGPipeKind. + // + // EXHAUSTIVE, WITH NO `default:`, for MG_IntegrationTest/Harness/PipeSlotPeek.cpp's reason: + // a kind added to MGPipeKind without a decision here must be a -Wswitch warning in this + // file rather than a row that silently inherits somebody else's answer. Being wrong in the + // "covered" direction is the expensive one - a control asserting a corruption nobody can + // produce is a permanently red always-on lane - so an undecided kind must never read true, + // and with no `default:` there is no arm for it to read true from. + // + // constexpr AND PINNED BY static_assert BELOW, which is what stops it rotting the way a + // predicate with no caller does: MagmaPipeIdentityTables mints exactly two kinds, the + // asserts say so in both directions, and the file no longer compiles if the tables and this + // statement of them ever part company. (Review F-m5: the earlier form had no caller at all + // and could not make anything red or green.) + inline constexpr Bool MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind kind) { + switch (kind) { + // The two MagmaPipeIdentityTables really mints. + case MG_Pipe::MGPipeKind::VertexElementsCso: + case MG_Pipe::MGPipeKind::Buffer: + return true; + // P4a's six object classes: still reached from their frontend objects on this + // backend (Magma's object paths are P7, ROADMAP.md:24), so there is no key here for + // the knob to defeat. + case MG_Pipe::MGPipeKind::Texture: + case MG_Pipe::MGPipeKind::Renderbuffer: + case MG_Pipe::MGPipeKind::Framebuffer: + case MG_Pipe::MGPipeKind::SamplerCso: + case MG_Pipe::MGPipeKind::SamplerViewCso: + case MG_Pipe::MGPipeKind::ShaderCso: + // ...and everything else this backend does not mint a handle for. + case MG_Pipe::MGPipeKind::None: + case MG_Pipe::MGPipeKind::Xfb: + case MG_Pipe::MGPipeKind::RenderStateCso: + case MG_Pipe::MGPipeKind::Fence: + case MG_Pipe::MGPipeKind::Query: + case MG_Pipe::MGPipeKind::Context: + case MG_Pipe::MGPipeKind::KindCount: + return false; + } + return false; + } + + static_assert(MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::VertexElementsCso), + "MagmaPipeIdentityTables mints VertexElementsCso: the knob has an identity to " + "defeat for it"); + static_assert(MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Buffer), + "MagmaPipeIdentityTables mints Buffer: the knob has an identity to defeat for it"); + static_assert(!MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Texture) && + !MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Renderbuffer) && + !MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::Framebuffer) && + !MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::SamplerCso) && + !MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::SamplerViewCso) && + !MagmaPipeAbaControlKindIsRekeyedHere(MG_Pipe::MGPipeKind::ShaderCso), + "P4a's six object classes are not keyed on {slot, gen} on this backend, so " + "HandleRecycleScenario's six AbaControl arms must NOT expect a corruption here. " + "Wiring one of them is what flips this assert, this predicate and that arm - and " + "MG_IntegrationTest's two-symbol probe over MG_Backend/DirectVulkan is what " + "carries the answer into the lane"); + + // THERE IS DELIBERATELY NO PER-KIND WRAPPER HERE, and review F-v2-m3 is why. An earlier + // round carried `MagmaPipeAbaControlCoversKind(kind)` - the conjunction of the two + // statements above - and it had no caller anywhere in the tree: the knob's only two + // consumers (VulkanRenderer.cpp's VAO draw memo and VertexInputStateFactory.cpp's pipeline + // key) each hold ONE kind, VertexElementsCso, by construction, so the kind is not a + // variable at either site. A conjunction no build ever evaluates cannot be pinned the way + // the predicate above is pinned - it is not constexpr, because it reads MG_Config::Features, + // so no static_assert can reach it - which makes it exactly the rot F-m5 was raised about, + // one level up: an `&&` whose operands could be inverted or dropped with nothing to say so. + // + // The two pieces stand alone instead, and each is pinned by something that runs: + // MagmaPipeAbaControlKindIsRekeyedHere is constexpr and asserted in BOTH directions by the + // three static_asserts above, which compile in every Magma build; MagmaPipeAbaControlDefeats + // Identity is the knob, and its two consumers are what make it true or false. A call site + // that ever does hold a variable kind writes the `&&` there, where a build will run it. + + // The single consumer-table entry every VAO collapses onto while the control is on. Slot + // 0 is a real, ordinary entry of both tables (MagmaPipeSlotIndex maps the first allocatable + // handle onto it), so nothing about the tables changes shape for the control's sake. + inline constexpr Uint32 kMagmaPipeAbaControlSlotIndex = 0; + + // --------------------------------------------------------------------------------- + // The {slot, gen} mint + // --------------------------------------------------------------------------------- + // + // Maps a frontend object's never-reused lifetime id to a dense {slot, gen}. Three + // properties, and the third is the one review v2 got wrong: + // + // 1. exact identity - Gen moves whenever a slot changes owner, so a stale handle can + // never match a live object even if the allocator hands back the same heap address + // (the ABA HandleRecycleScenario reproduces); + // 2. dense slots - the slot IS an index, so a consumer's per-slot table needs no hash, + // no probe and no mix; + // 3. NO CAPACITY CLIFF. A live object's handle never changes while the object is being + // drawn, whatever the working set size. + // + // Property 3 is why this is not the fixed 2-way set-associative LRU the previous round + // shipped. That structure evicted a LIVE object once the working set passed its capacity, + // and every consumer memo keyed on the handle died with it: measured on a verbatim + // transcription, 54% of uses lost their handle at 2500 live VAOs against 2048 entries, and + // 20% at 1024 live VAOs once the lifetime ids are sparse (an app that creates and destroys + // VAOs, which is the Minecraft chunk shape this exists for). Two of the three memos it + // fed - the content-hash memo and the resolved-state memo - had NO capacity before this + // package: they were unbounded mutable fields on VertexArrayObject. Introducing eviction + // there turns one ComputeHash per VAO reconfiguration into one per DRAW, and, once the + // buffer table thrashes too, makes the vertex-input content hash a per-draw value that + // inserts a fresh heap-allocated BackendVertexInputState into an unbounded map on every + // draw. That is a worse leak than the one it was introduced to avoid. + // + // So: grow on demand, and reclaim by AGE instead of by capacity. + // + // * Acquire hits an UnorderedMap, in front of which sits a + // one-entry memo. Every re-keyed site in a draw asks about the SAME VAO, so the memo + // turns the five-or-six acquisitions a draw makes into one map probe plus five Uint64 + // compares - less than the address multiply plus two-way probe the pre-handle arm ran. + // * OnFrameBoundary retires slots whose object has not been drawn for + // kRetireAgeBoundaries boundaries and returns them to a free list, so the table's + // footprint tracks the LIVE DRAWN working set, not objects ever created. That is the + // property MG_Impl/Pipe/SlotAllocator cannot have here: nothing in P2 can call its + // Free (the tracker emits no object-class state, BufferBackendOps::OnDestroy is handed + // a BackendBufferResource rather than the BufferObject, and VertexArrayObject has no + // death hook at all - adding one is D13's explicit-destroy work, which covers Espryt's + // six kinds, not VertexElementsCso), so an allocator here would grow by one SlotState + // plus one map node per object EVER created, for the life of the process, on a + // platform with an LMK. Age-based reclamation is the stand-in for the death + // notification, and it is exactly as ABA-proof, because reuse bumps Gen. + // * A retire costs at most one memo recompute if the object is drawn again - the same + // price a cache miss costs - and it is charged only to objects that went idle for + // ~1024 frames, never to a hot one. + // + // Memory: one map node plus one 24-byte Entry per live object, i.e. tens of bytes against + // the kilobyte a VertexArrayObject or a BufferObject already costs the frontend. There is + // no capacity to size off a device measurement because there is no capacity; what the + // device run in D.4.2 can still want is the number itself, so the high-water mark is + // logged at MGLOG_D on the allocate-a-new-slot branch (once per new object, never on a + // draw - ROADMAP.md:7). + // + // Single-threaded, like the rest of the renderer. Owned per VulkanRenderer (see + // MagmaPipeIdentityTables): a process-global would share one table, and one reclamation + // clock, across two live contexts. + class MagmaPipeIdentityTable { + public: + explicit MagmaPipeIdentityTable(const char* kindName) : m_kindName(kindName) {} + + // Slots ever minted. A consumer table indexed by MagmaPipeSlotIndex() needs this many + // entries; MagmaPipeSlotTable below grows itself, so nobody has to ask. + Uint32 Count() const { return static_cast(m_entries.size()); } + // Objects currently holding a slot - the live working set this table tracks. + Uint32 LiveCount() const { return static_cast(m_index.size()); } + + MG_Pipe::MGPipeHandle Acquire(Uint64 lifetimeId) { + // Unreachable: MG_State hands out lifetime ids from 1 precisely so that a + // zero-initialised memo slot cannot carry a live object's id. Guarded anyway so + // that a zero can never be minted into a slot and then indexed with. + if (lifetimeId == 0) return MG_Pipe::kMGPipeNullHandle; + // The one-entry front memo. Cleared by any retire, so it can never serve a slot + // that has been handed back to the free list. + if (lifetimeId == m_lastLifetimeId) { + m_entries[m_lastIndex].LastUse = m_boundary; + return m_lastHandle; + } + Uint32 index = 0; + const auto it = m_index.find(lifetimeId); + if (it != m_index.end()) { + index = it->second; + } else { + index = ClaimSlot(); + m_entries[index].LifetimeId = lifetimeId; + m_index.emplace(lifetimeId, index); + } + Entry& entry = m_entries[index]; + entry.LastUse = m_boundary; + m_lastLifetimeId = lifetimeId; + m_lastIndex = index; + m_lastHandle = MG_Pipe::MGPipeHandle{index + MG_Pipe::kMGPipeFirstAllocatableSlot, + entry.Gen}; + return m_lastHandle; + } + + // Ages the table and returns idle slots to the free list. Same shape and the same + // self-gating as VertexInputStateFactory::OnFrameBoundary, which is what the reclaimed + // slots' consumers use. + void OnFrameBoundary() { + ++m_boundary; + if ((m_boundary % kSweepInterval) != 0) return; + SizeT retired = 0; + for (auto it = m_index.begin(); it != m_index.end();) { + Entry& entry = m_entries[it->second]; + if ((m_boundary - entry.LastUse) > kRetireAgeBoundaries) { + entry.LifetimeId = 0; + m_freeSlots.push_back(it->second); + it = m_index.erase(it); + ++retired; + } else { + ++it; + } + } + if (retired != 0) { + // A retired slot's Gen has not moved yet - it moves when the slot is reused - + // so a front memo pointing at one would still hand out a handle the consumer + // tables would accept. Drop it. + m_lastLifetimeId = 0; + m_lastHandle = MG_Pipe::kMGPipeNullHandle; + MGLOG_D("MagmaPipeIdentityTable(%s): retired %zu idle slots, %u live of %u minted", + m_kindName, retired, LiveCount(), Count()); + } + } + + private: + // Sweep cadence and retirement age, deliberately the same numbers + // VertexInputStateFactory::OnFrameBoundary uses for the entries these slots key: a slot + // retired earlier than its cache entry would mint a new handle for an object whose + // entry is still live and still correct, which is a pure waste. + static constexpr Uint64 kSweepInterval = 256; + static constexpr Uint64 kRetireAgeBoundaries = 1024; + + struct Entry { + Uint64 LifetimeId = 0; + Uint64 LastUse = 0; + // Moves ONLY on slot reuse, never on respecify: an object that keeps its slot keeps + // its generation, which is what makes a memo survive a reconfiguration. + Uint32 Gen = 0; + }; + + Uint32 ClaimSlot() { + while (!m_freeSlots.empty()) { + const Uint32 index = m_freeSlots.back(); + m_freeSlots.pop_back(); + // MGPipeHandles.h:52-58 defends the Gen wrap only in a debug allocator, and + // MOBILEGL_ASSERT is compiled out of every build P2 runs (Defines.h: asserts are + // live only at MOBILEGL_LOG_ACTIVE_LEVEL == DEBUG). So the wrap is handled on the + // RELEASE path instead of asserted: a slot that has been reused 2^32 times is + // permanently retired rather than wrapped, because a wrapped Gen would let a + // stale handle match a live object. It costs one slot. + if (m_entries[index].Gen == ~Uint32{0}) { + MGLOG_W("MagmaPipeIdentityTable(%s): slot %u reached generation 2^32-1 and is " + "retired for good; {slot, gen} stays unique", + m_kindName, index + MG_Pipe::kMGPipeFirstAllocatableSlot); + continue; + } + ++m_entries[index].Gen; + return index; + } + const Uint32 index = static_cast(m_entries.size()); + m_entries.push_back(Entry{}); + m_entries[index].Gen = 1; + // The high-water mark, at powers of two from 1024 up: at most a handful of lines + // for a whole session, emitted from the allocate-a-NEW-slot branch, i.e. once per + // object this backend has ever seen and never on a draw (ROADMAP.md:7). + // + // [narrow, declared deviation from D20's "MGLOG_D for anything non-critical"] This + // one is I, not D, because D is compiled out of every build that ships and of every + // build P2 measures, and this line IS the measurement review v2's MAJOR 1 asks for: + // the live-object high-water mark of minecraft-1.21.4-in-world and + // ...-sodium-in-world, which nothing on desktop reaches and no gate here can see. + // The structure no longer has a capacity to size off it, so the number is evidence + // rather than a tuning input - but D.4.2 should still read it out of the device log, + // and it cannot read a line that was compiled away. + const SizeT minted = m_entries.size(); + if (minted >= 1024 && (minted & (minted - 1)) == 0) { + MGLOG_I("MagmaPipeIdentityTable(%s): high-water %zu slots minted, %u live", + m_kindName, minted, LiveCount()); + } + return index; + } + + const char* m_kindName = ""; + Uint64 m_boundary = 0; + Vector m_entries; + Vector m_freeSlots; + UnorderedMap m_index; + // One-entry front memo (see Acquire). m_lastLifetimeId == 0 means "empty": a live + // object's lifetime id is never 0. + Uint64 m_lastLifetimeId = 0; + Uint32 m_lastIndex = 0; + MG_Pipe::MGPipeHandle m_lastHandle = MG_Pipe::kMGPipeNullHandle; + }; + + // The two mints one renderer owns. Per renderer, NOT process-global: two live contexts (or + // a context recreation, which destroys and rebuilds the renderer) would otherwise share one + // table and one reclamation clock, and both consumer tables are per-instance already. + class MagmaPipeIdentityTables { + public: + // A VAO is kind VertexElementsCso: that is the gallium-shaped CSO a vertex array + // resolves to, and the only kind in MGPipeKind that names vertex-input state. + MG_Pipe::MGPipeHandle HandleOf(MG_Pipe::MGPipeKind kind, Uint64 lifetimeId) { + return kind == MG_Pipe::MGPipeKind::Buffer ? m_buffers.Acquire(lifetimeId) + : m_vaos.Acquire(lifetimeId); + } + void OnFrameBoundary() { + m_vaos.OnFrameBoundary(); + m_buffers.OnFrameBoundary(); + } + const MagmaPipeIdentityTable& Vaos() const { return m_vaos; } + const MagmaPipeIdentityTable& Buffers() const { return m_buffers; } + + private: + MagmaPipeIdentityTable m_vaos{"VertexElementsCso"}; + MagmaPipeIdentityTable m_buffers{"Buffer"}; + }; + + // The table entry a handle names. Every per-slot table Magma keeps is indexed by this. + // + // A null handle has no slot, and it is unreachable here: both lifetime-id sources start at + // 1 (VertexArrayObject.cpp, BufferObject.cpp), so Acquire's zero guard never fires. The + // ternary, not the assertion, is what has effect in a shipped build (Defines.h compiles + // MOBILEGL_ASSERT out at INFO), and slot 0 of a consumer table is a real entry that a null + // handle can never match, because MGPipeHandleIsNull is also what the consumers compare. + inline Uint32 MagmaPipeSlotIndex(const MG_Pipe::MGPipeHandle& handle) { + MOBILEGL_ASSERT(!MG_Pipe::MGPipeHandleIsNull(handle), + "a null MGPipeHandle has no slot to index a per-slot table with"); + return MG_Pipe::MGPipeHandleIsNull(handle) + ? 0u + : handle.Slot - MG_Pipe::kMGPipeFirstAllocatableSlot; + } + + // A grow-on-demand per-slot table whose ENTRY ADDRESSES NEVER MOVE. + // + // D12.4 asks for a grow-on-demand Vector, and with an unbounded mint that is what a + // consumer needs - but a Vector that grows relocates its elements, and the draw path holds + // references into these entries across nested calls. Chunks of kChunkEntries are appended + // instead: the Vector of owning pointers reallocates, the chunks never do, so an entry + // reference is valid for the life of the table. That is the same guarantee the fixed table + // it replaces gave, without the fixed capacity. + template + class MagmaPipeSlotTable { + public: + T& operator[](Uint32 index) { + const Uint32 chunk = index / kChunkEntries; + while (m_chunks.size() <= chunk) { + m_chunks.push_back(MakeUnique()); + } + return m_chunks[chunk]->Entries[index % kChunkEntries]; + } + SizeT Capacity() const { return m_chunks.size() * kChunkEntries; } + + private: + struct Chunk { + T Entries[kChunkEntries] = {}; + }; + Vector> m_chunks; + }; + + // The claim rule every per-slot memo table uses, in one place so that the rule and the + // negative control that defeats it cannot drift apart between consumers - and so that the + // unit suite which drives a REAL slot reuse (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) tests + // this code rather than a copy of it. + // + // The SLOT picks the entry; the WHOLE handle - Gen included - decides whether the entry is + // this object's. A slot the mint recycled for a different object comes back with a moved + // Gen, so the compare fails and the entry is cleared rather than inherited. That is the + // half HandleRecycleScenario cannot reach (see MagmaPipeAbaControlDefeatsIdentity). + // + // With negative control C on, every object collapses onto one entry and the entry is handed + // back UNCLEARED and UNCLAIMED - at once "the replacement reproduced its predecessor's + // slot" and "the slot was reused and Gen did not move". + // + // `Memos` needs a MG_Pipe::MGPipeHandle member named Owner and a default constructor that + // means "empty"; VertexInputStateFactory::VaoBackendMemos is the one production instance. + template + inline Memos& MagmaPipeClaimSlotMemos(MagmaPipeSlotTable& table, + const MG_Pipe::MGPipeHandle& handle) { + if (MagmaPipeAbaControlDefeatsIdentity()) { + return table[kMagmaPipeAbaControlSlotIndex]; + } + Memos& memos = table[MagmaPipeSlotIndex(handle)]; + if (!(memos.Owner == handle)) { + memos = Memos{}; + memos.Owner = handle; + } + return memos; + } +#endif // MOBILEGL_PIPE_PUSH +} // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp index a2743a168..f06bb9f17 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/SwapchainObject.cpp @@ -11,6 +11,11 @@ #include "MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.h" #include "MG_State/GLState/TextureState/TextureObject2D.h" +#if MOBILEGL_PIPE_PUSH +// P5c ev: the surface-changed event's producer callback, installed by the server session. +#include +#endif + #if defined(__has_include) #if __has_include() #include @@ -280,11 +285,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(defaultFramebufferExtent.width) * static_cast(defaultFramebufferExtent.height) * 4; - auto* colorTex = static_cast(defaultFBOInfo->colorAttachment.get()); - colorTex->AllocateStorage( - TextureUploadTarget::Texture2D, 0, { - {extentWidth, extentHeight, 1}, - defaultAttachmentByteSize}); // TODO: 4 is format size TextureInternalFormat depthFormat = TextureInternalFormat::Depth24Stencil8; switch (m_depthStencilFormat) { case VK_FORMAT_D24_UNORM_S8_UINT: @@ -300,11 +300,6 @@ namespace MobileGL::MG_Backend::DirectVulkan { depthFormat = TextureInternalFormat::Depth24Stencil8; break; } - auto* depthTex = static_cast(defaultFBOInfo->depthAttachment.get()); - depthTex->SetInternalFormat(depthFormat); - depthTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { - {extentWidth, extentHeight, 1}, - defaultAttachmentByteSize}); // TODO: 4 is format size // The default FBO's stencil attachment must track the swapchain extent: // FramebufferObject::CheckCompleteness requires every valid attachment @@ -324,11 +319,44 @@ namespace MobileGL::MG_Backend::DirectVulkan { stencilFormat = depthFormat; break; } - auto* stencilTex = static_cast(defaultFBOInfo->stencilAttachment.get()); - stencilTex->SetInternalFormat(stencilFormat); - stencilTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { - {extentWidth, extentHeight, 1}, - defaultAttachmentByteSize}); // TODO: 4 is format size + +#if MOBILEGL_PIPE_PUSH + if (MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged != nullptr) { + // P5c ev (CONTRACT-P5C §4.2): with an active transport the default FBO's + // attachments are CLIENT memory and this thread may not write them - the backend + // fills MGPSurfaceInfo and posts, and the client's consumer replays exactly the + // allocate/format writes of the monolith arm below on the GL thread. The two + // formats are always equal by the switches above, so one InternalFormat carries + // both. The callback's presence IS the transport probe - the server session + // installs it at Accept, and under monolith nobody ever does. + MG_Pipe::MGPSurfaceInfo info{}; + info.Width = defaultFramebufferExtent.width; + info.Height = defaultFramebufferExtent.height; + info.InternalFormat = static_cast(depthFormat); + info.Samples = 1; + info.Layers = 1; + info.IsDefault = 1; + MG_Pipe::gMGPipeCallbacks.OnSurfaceChanged(&info); + } else +#endif + { + auto* colorTex = static_cast(defaultFBOInfo->colorAttachment.get()); + colorTex->AllocateStorage( + TextureUploadTarget::Texture2D, 0, { + {extentWidth, extentHeight, 1}, + defaultAttachmentByteSize}); // TODO: 4 is format size + auto* depthTex = static_cast(defaultFBOInfo->depthAttachment.get()); + depthTex->SetInternalFormat(depthFormat); + depthTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { + {extentWidth, extentHeight, 1}, + defaultAttachmentByteSize}); // TODO: 4 is format size + + auto* stencilTex = static_cast(defaultFBOInfo->stencilAttachment.get()); + stencilTex->SetInternalFormat(stencilFormat); + stencilTex->AllocateStorage(TextureUploadTarget::Texture2D, 0, { + {extentWidth, extentHeight, 1}, + defaultAttachmentByteSize}); // TODO: 4 is format size + } } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp index a2465a582..668c12648 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/UniformManager.cpp @@ -10,6 +10,11 @@ #include "MG_Backend/DirectVulkan/DirectVulkanResourceState.h" #include "MG_State/GLState/Core.h" +#include +#if MOBILEGL_PIPE_PUSH +// P5c ev: the GPU-write announcement routes through the reverse channel (R2). +#include +#endif #include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/TextureState/TextureObject1D.h" #include "MG_State/GLState/TextureState/TextureObject2D.h" @@ -20,6 +25,7 @@ #include "MG_Util/Converters/GLToMG/TextureEnumConverter.h" #include "MG_Util/Converters/MGToStr/FramebufferEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" +#include "MG_Util/Metrics/PipeStats.h" #include "MG_Util/Metrics/TextureMetrics.h" #include "MG_Util/ShaderTranspiler/Types.h" #include @@ -502,7 +508,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // alive through the draw via GL binding state. Only the fallback path needs a SharedPtr to // keep the fallback texture alive for the rest of this call. MG_State::GLState::ITextureObject* texture = ResolveSamplerTextureRaw(program, programObj, binding, element); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const auto& samplerOverride = textureUnit.GetSamplerObject(); const auto preferredTarget = programObj.samplerTextureTargetByBinding[binding]; SharedPtr fallbackHolder; @@ -551,7 +557,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } if (!IsValidSampledImageLayout(resource->layout)) { - auto drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + auto drawFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); FramebufferAttachmentType attachmentType = FramebufferAttachmentType::None; Int attachmentLevel = 0; if (drawFbo && @@ -778,7 +784,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // filtering - which a single-level view can still have. Resolve the sampler exactly // the way ResolveSamplerDescriptor does and bail if anisotropy would apply. const Int unit = ResolveSamplerUnitIndex(program, location, binding); - const auto& samplerOverride = MG_State::pGLContext->GetTextureUnitObject(unit).GetSamplerObject(); + const auto& samplerOverride = MGB_CTX->GetTextureUnitObject(unit).GetSamplerObject(); const auto* effectiveSampler = samplerOverride ? samplerOverride.get() : texture->GetSamplerObject().get(); if (effectiveSampler == nullptr) return false; @@ -808,7 +814,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const ProgramFactory::VkProgramObject& programObj, Uint32 binding, SharedPtr& outTexture) { outTexture.reset(); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSamplerTexture: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveSamplerTexture: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveSamplerTexture: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), @@ -817,7 +823,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Int location = programObj.samplerUniformLocationByBinding[binding]; const Int unit = ResolveSamplerUnitIndex(program, location, binding); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; outTexture = textureUnit.GetBindingSlot(preferredTarget).GetBoundObject(); // The slot always holds at least the target's default texture (name 0). While that @@ -833,7 +839,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { MG_State::GLState::ITextureObject* UniformManager::ResolveSamplerTextureRaw( const MG_State::GLState::ProgramObject& program, const ProgramFactory::VkProgramObject& programObj, Uint32 binding, Uint32 element) { - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSamplerTextureRaw: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveSamplerTextureRaw: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveSamplerTextureRaw: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), @@ -843,7 +849,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { ResolveDescriptorElementLocation(program, programObj.samplerUniformLocationByBinding[binding], element); const Int unit = ResolveSamplerUnitIndex(program, location, binding); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; // GetBoundObject() returns the SharedPtr by const ref; .get() reads the pointer without // touching the refcount (no atomic inc/dec per binding per draw). @@ -988,7 +994,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkBufferView& outBufferView) { outBufferView = VK_NULL_HANDLE; MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageTexelBufferDescriptor: buffer manager is null"); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveStorageTexelBufferDescriptor: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveStorageTexelBufferDescriptor: GL context is null"); MOBILEGL_ASSERT(frameIndex < m_frames.size(), "ResolveStorageTexelBufferDescriptor: frame index out of range"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), @@ -1012,7 +1018,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { MOBILEGL_ASSERT(binding < programObj.samplerNumericDomainByBinding.size(), "ResolveStorageTexelBufferDescriptor: numeric domain binding %u out of range", binding); - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit); + auto& imageBinding = MGB_CTX->GetImageTextureBinding(imageUnit); const auto& texture = imageBinding.Texture; if (texture == nullptr) { // An image unit with no texture on it is legal GL (4.6 core 8.26): loads return zero @@ -1070,7 +1076,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // changed a byte of it. bufferObject->EnsureGpuResidentStorage(); if (imageBinding.Access != GL_READ_ONLY) { +#if MOBILEGL_PIPE_PUSH + // P5c ev (R2, CONTRACT-P5C §4.2): the announcement goes through the reverse + // channel - the apply thread may not poke the client object directly. + MG_Pipe::MGPipeAnnounceBufferGpuWritten(bufferObject); +#else bufferObject->MarkGpuWritten(); +#endif } BufferSlice slice{}; @@ -1148,7 +1160,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkDescriptorBufferInfo& outBufferInfo) const { outBufferInfo = {}; MOBILEGL_ASSERT(m_bufferManager != nullptr, "ResolveStorageBufferDescriptor: buffer manager is null"); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveStorageBufferDescriptor: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveStorageBufferDescriptor: GL context is null"); MOBILEGL_ASSERT(binding < programObj.storageBlockIndexByBinding.size(), "ResolveStorageBufferDescriptor: binding %u out of range", binding); @@ -1185,12 +1197,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { ? static_cast(atomicCounterBinding) : GetShaderStorageBlockBinding(program, static_cast(blockIndex)) + element; const Uint32 bindingPointCount = - static_cast(MG_State::pGLContext->GetBufferBindingPointCount(bufferTarget)); + static_cast(MGB_CTX->GetBufferBindingPointCount(bufferTarget)); MOBILEGL_ASSERT(frontendBinding < bindingPointCount, "ResolveStorageBufferDescriptor: frontend binding %u out of range for block '%s'", frontendBinding, blockName.c_str()); - auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(bufferTarget, frontendBinding); + auto& bindingPoint = MGB_CTX->GetBufferBindingPoint(bufferTarget, frontendBinding); const auto& bufferObject = bindingPoint.GetBoundObject(); if (bufferObject == nullptr) { // NOT an error, and above all not a reason to lose the draw. GL 4.6 core 7.8 lets a @@ -1226,7 +1238,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // results are visible without a readback path, exactly as for a capture buffer. bufferObject->EnsureGpuResidentStorage(); // ... and the read that follows has to wait for this draw or dispatch to retire. +#if MOBILEGL_PIPE_PUSH + // P5c ev (R2, CONTRACT-P5C §4.2): through the reverse channel, not a direct poke of + // the client object from the apply thread. + MG_Pipe::MGPipeAnnounceBufferGpuWritten(bufferObject); +#else bufferObject->MarkGpuWritten(); +#endif BufferSlice slice{}; if (!m_bufferManager->AcquireResidentSlice(BufferKind::ShaderStorage, bufferObject, slice) || !slice.IsValid()) { @@ -1262,7 +1280,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { VkDescriptorImageInfo& outImageInfo) const { outImageInfo = {}; MOBILEGL_ASSERT(m_textureManager != nullptr, "ResolveStorageImageDescriptor: texture manager is null"); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveStorageImageDescriptor: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveStorageImageDescriptor: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveStorageImageDescriptor: binding %u out of range", binding); @@ -1291,7 +1309,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } - auto& imageBinding = MG_State::pGLContext->GetImageTextureBinding(imageUnit); + auto& imageBinding = MGB_CTX->GetImageTextureBinding(imageUnit); if (imageBinding.Texture == nullptr) { // Legal GL: an image unit with no texture bound makes loads return zero and discards // stores (4.6 core 8.26). It is not a reason to lose the draw, which is what returning @@ -1647,7 +1665,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Open-coded ResolveSamplerTextureRaw so the unit is resolved once for both the // texture and the sampler override - this runs per binding per full-path draw, // and program-alternating draw streams take the full path on every draw. - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveSampledBinding: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveSampledBinding: GL context is null"); MOBILEGL_ASSERT(binding < programObj.samplerUniformLocationByBinding.size(), "ResolveSampledBinding: sampler location binding %u out of range", binding); MOBILEGL_ASSERT(binding < programObj.samplerTextureTargetByBinding.size(), @@ -1658,7 +1676,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } const Int unit = ResolveSamplerUnitIndex(program, location, binding); - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(unit); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(unit); const TextureTarget preferredTarget = programObj.samplerTextureTargetByBinding[binding]; MG_State::GLState::ITextureObject* texture = textureUnit.GetBindingSlot(preferredTarget).GetBoundObject().get(); @@ -1802,7 +1820,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const ProgramFactory::VkProgramObject& programObj, Vector& outTextures) const { outTextures.clear(); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, + MOBILEGL_ASSERT(MGB_CTX_LIVE, "CollectStorageImageTextures: GL context is null"); // Same as the sampled walk: a declined program is refused at bind time, and its declined // binding has no uniform location to reach an image unit through. @@ -1846,7 +1864,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { return false; } - auto* texture = MG_State::pGLContext->GetImageTextureBinding(imageUnit).Texture.get(); + auto* texture = MGB_CTX->GetImageTextureBinding(imageUnit).Texture.get(); if (texture == nullptr) { // ResolveStorageImageDescriptor will substitute the placeholder image for this // binding; include it here for the same reason the sampled walk includes the @@ -1884,7 +1902,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const ProgramFactory::VkProgramObject& programObj, Vector& outBindings) const { outBindings.clear(); - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, + MOBILEGL_ASSERT(MGB_CTX_LIVE, "CollectSamplerImageFeedback: GL context is null"); if (programObj.declinedDescriptors) return true; @@ -1934,7 +1952,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (imageUnit < 0 || imageUnit >= MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS) { return false; } - const auto& image = MG_State::pGLContext->GetImageTextureBinding(imageUnit); + const auto& image = MGB_CTX->GetImageTextureBinding(imageUnit); // A sampler view exposes all layers of its target; equal texture plus an // overlapping mip therefore aliases the writable image subresource. if (image.Texture.get() == sampledTexture && @@ -1964,7 +1982,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const void* outData = nullptr; VkDeviceSize outSize = 0; - MOBILEGL_ASSERT(MG_State::pGLContext != nullptr, "ResolveUniformBufferPayload: GL context is null"); + MOBILEGL_ASSERT(MGB_CTX_LIVE, "ResolveUniformBufferPayload: GL context is null"); MOBILEGL_ASSERT(binding < programObj.bindingKinds.size(), "ResolveUniformBufferPayload: binding %u out of range", binding); MOBILEGL_ASSERT(programObj.bindingKinds[binding] == ProgramFactory::DescriptorBindingKind::UniformBufferDynamic, @@ -2009,12 +2027,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Uint32 frontendBinding = program.GetUniformBlockBinding(static_cast(blockIndex)); const Uint32 uniformBindingPointCount = - static_cast(MG_State::pGLContext->GetBufferBindingPointCount(BufferTarget::Uniform)); + static_cast(MGB_CTX->GetBufferBindingPointCount(BufferTarget::Uniform)); MOBILEGL_ASSERT(frontendBinding < uniformBindingPointCount, "ResolveUniformBufferPayload: frontend UBO binding %u out of range for block '%s'", frontendBinding, program.GetUniformBlockName(static_cast(blockIndex)).c_str()); - auto& bindingPoint = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::Uniform, frontendBinding); + auto& bindingPoint = MGB_CTX->GetBufferBindingPoint(BufferTarget::Uniform, frontendBinding); const auto& bufferObject = bindingPoint.GetBoundObject(); MOBILEGL_ASSERT(bufferObject != nullptr, "ResolveUniformBufferPayload: no UBO bound at frontend binding %u for block '%s'", @@ -2076,6 +2094,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { out.dynamicOffset = rangeStart; } } + if (MG_Util::PipeStats::Enabled() && !out.directBindable) { + // D-B8: the bytes Magma repacks into its own UBO ring, i.e. exactly the host + // payload a split build would have to ship with set_shader_buffers. Espryt binds + // the frontend buffer to the driver and contributes nothing here, which is why + // the class is named for the payload and not for the call. Counted AFTER the + // zero-copy direct-bind decision: a direct bind repacks nothing, and counting it + // here reported a copy that never happened. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboNamed, + static_cast(outSize)); + } return true; } @@ -2283,6 +2311,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { outBuffer = slice.buffer; outRange = ubo.payloadSize; outDynamicOffset = static_cast(slice.offset); + if (isGlobalUbo && MG_Util::PipeStats::Enabled()) { + // Magma's half of stage-ubo-global, so the class means the same on both + // backends. The memo hit above returns before this, so a frame that reuses the + // slice correctly contributes nothing. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboGlobal, + static_cast(ubo.payloadSize)); + } if (isGlobalUbo) { m_globalUboMemo[m_globalUboMemoNext] = GlobalUboSliceMemo{uboProgramLifetimeId, uboFrameSerial, uboContentVersion, diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp index 62b68c6dd..7e96cf967 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.cpp @@ -7,8 +7,13 @@ // End of Source File Header #include "VertexInputStateFactory.h" +#include "MagmaPipeArms.h" #include "MG_Util/Converters/MGToStr/DataTypeConverter.h" #include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#endif #include namespace MobileGL::MG_Backend::DirectVulkan { @@ -45,25 +50,149 @@ namespace MobileGL::MG_Backend::DirectVulkan { // capture came back holding a dead VAO's vertex data (0,0,0,1 - the previous // test's positions) instead of its own. // Zero for client memory (no buffer), which is a distinct identity of its own. - const Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0; + // + // P2 D12.4 / ARCHITECTURE.md 9.5: under the handle arm the identity is the + // buffer's {slot, gen} rather than its lifetime id - "lifetimeId -> gen mixed + // into every server-side content hash". The two are equally ABA-proof (the + // allocator maps one onto the other and bumps Gen only on slot REUSE); what + // changes is that the key is now the identity the SERVER will be handed once + // buffers travel as handles, instead of a number only the client can mint. + Uint64 bufferKey = attr.Buffer ? attr.Buffer->GetLifetimeId() : 0; +#if MOBILEGL_PIPE_PUSH + if (attr.Buffer) { + // The SAME arm question the other four re-keyed sites ask, through the same + // helper: a site that decided for itself could silently key on the pre-handle + // identity while its neighbours keyed on the handle. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + const MG_Pipe::MGPipeHandle handle = + m_identity->HandleOf(MG_Pipe::MGPipeKind::Buffer, attr.Buffer->GetLifetimeId()); + bufferKey = static_cast(handle.Slot) | (static_cast(handle.Gen) << 32); + } + if (MagmaPipeAbaControlDefeatsIdentity()) { + // Negative control C (P2 brief D18), on WHICHEVER arm this run is on - the + // pre-handle lifetime id and the handle's {slot, gen} are the same guard + // wearing two hats, and a control that defeated only the retired one would + // say nothing about the key P2 ships. + // + // The identity is replaced by a constant rather than by the raw + // BufferObject*, because the address is not recycled in practice and so + // never collides (see MagmaPipeAbaControlDefeatsIdentity). Zero is what a + // key with NO buffer identity in it looks like - the exact defect this + // hash was fixed for: "the hash is what TryBindResolvedVertexBindings + // accepts as proof that a memoised binding still reads the buffer it was + // resolved from", and with the identity gone it accepts a binding resolved + // from a different buffer. HandleRecycleScenario.AbaControl then draws a + // replacement VAO and gets its dead predecessor's vertex data. + bufferKey = 0; + } + } +#endif XXHASH_VERIFY(XXH64_update(m_hashState, &bufferKey, sizeof(bufferKey))); } return XXH64_digest(m_hashState); } +#if MOBILEGL_PIPE_PUSH + VertexInputStateFactory::VaoBackendMemos& VertexInputStateFactory::MemosFor( + const MG_State::GLState::VertexArrayObject& vao) const { + const MG_Pipe::MGPipeHandle handle = + m_identity->HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, vao.GetLifetimeId()); + // One entry per mintable slot, grown on demand: the mint has no capacity, so neither + // does this, and no two live VAOs can share an entry however large the working set is. + // There is no probe in front of it because the mint itself is one - a one-entry memo + // hit for every acquisition after this draw's first, and a hash probe otherwise. + // + // The claim rule - the slot picks the entry, the whole handle (Gen included) decides + // whose it is - and negative control C's defeat of it are MagmaPipeArms.h's + // MagmaPipeClaimSlotMemos, so that the unit suite which drives a REAL slot reuse + // (MG_Test/Pipe/MagmaPipeIdentityTest.cpp) exercises this code and not a copy of it. + // What the control defeats HERE is the identity that SELECTS the entry: every VAO + // collapses onto one, handed back uncleared, so the replacement inherits the dead + // VAO's content hash and its resolved-entry pointer. The GENERATION half is the unit + // suite's business, for the reason MagmaPipeAbaControlDefeatsIdentity spells out. + return MagmaPipeClaimSlotMemos(m_vaoMemos, handle); + } +#endif + +#if MOBILEGL_PIPE_PUSH + Bool VertexInputStateFactory::TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao, + Uint64& outHash) const { + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + const VaoBackendMemos& memos = MemosFor(vao); + if (memos.HashConfigVersion != vao.GetConfigVersion()) return false; + outHash = memos.Hash; + return true; + } +#if MOBILEGL_PIPE_LEGACY_MEMOS + return vao.GetBackendHashMemo(outHash); +#else + return false; +#endif + } +#endif + VertexInputStateFactory::HashType VertexInputStateFactory::GetOrComputeHash( const MG_State::GLState::VertexArrayObject& vao) const { HashType hash = 0; +#if MOBILEGL_PIPE_PUSH + // P2 D12.5: the same memo, on the backend's side of the boundary. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + VaoBackendMemos& memos = MemosFor(vao); + if (memos.HashConfigVersion == vao.GetConfigVersion()) { + return memos.Hash; + } + hash = ComputeHash(vao); + memos.Hash = hash; + memos.HashConfigVersion = vao.GetConfigVersion(); + return hash; + } +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS if (!vao.GetBackendHashMemo(hash)) { hash = ComputeHash(vao); vao.SetBackendHashMemo(hash); } +#endif return hash; } const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState( const MG_State::GLState::VertexArrayObject& vao) { +#if MOBILEGL_PIPE_PUSH + // P2 D12.5: the same per-draw fast path, but the resolved-entry pointer lives in this + // factory's slot-indexed table instead of on the frontend VAO. The eviction epoch + // survives the move and is still what stops a stale pointer being dereferenced: the + // POINTEE is a cache entry this factory can erase at a frame boundary, and moving the + // memo does not change that. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + VaoBackendMemos& memos = MemosFor(vao); + if (memos.StateConfigVersion == vao.GetConfigVersion() && memos.State != nullptr && + memos.StateEpoch == m_evictionEpoch) { + const auto* memoEntry = static_cast(memos.State); + memoEntry->lastUsedFrameBoundary = m_frameBoundaryCounter; + return *memoEntry; + } + const BackendVertexInputState& resolved = + GetOrCreateVertexInputState(vao, GetOrComputeHash(vao)); + // MemosFor is re-taken rather than kept live across GetOrCreateVertexInputState: + // the reference is not worth holding across a call that can resize the table. + VaoBackendMemos& stamp = MemosFor(vao); + stamp.State = &resolved; + stamp.StateEpoch = m_evictionEpoch; + stamp.StateConfigVersion = vao.GetConfigVersion(); + // The AUX memo is deliberately NOT stamped here: its two words already live in + // VulkanRenderer::VaoDrawMemo (layoutHash / layoutAuxMasks) and its getter has no + // live reader anywhere, so the handle arm retires it rather than moving it. + return resolved; + } +#endif +#if !MOBILEGL_PIPE_LEGACY_MEMOS + // Unreachable: with no legacy arm compiled MagmaPipeTrackHArmIsHandles is a compile- + // time true, so the handle arm above always returns. Written out rather than left to + // fall off the end so the function still has a return on every path a compiler sees. + return GetOrCreateVertexInputState(vao, GetOrComputeHash(vao)); +#else // Per-draw fast path: the VAO carries a pointer to its resolved entry, // valid while its config version and the cache's eviction epoch both // match - no re-hash, no map lookup. @@ -83,6 +212,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { vao.SetBackendAuxMemo(entry.layoutHash, PackVertexInputAuxMasks(entry.unsupportedAttribMask, entry.attributeLocationMask)); return entry; +#endif // MOBILEGL_PIPE_LEGACY_MEMOS } const VertexInputStateFactory::BackendVertexInputState& VertexInputStateFactory::GetOrCreateVertexInputState( @@ -120,6 +250,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { // set, a dvec3/dvec4 would be declined by ToVkVertexFormat AND left 64-bit in the // module, so a float32 stream would be fed to a Float64 input. const Bool narrowFloat64Arrays = +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.7): with an active transport the answer is the + // SERVER's own backend's - the client caps mirror is client memory (rule E). + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? (MG_Remote::Server::ServerLoopInstance().Backend() == nullptr || + !MG_Remote::Server::ServerLoopInstance().Backend() + ->GetDynamicParameters() + .SupportsFloat64VertexAttributes) + : +#endif MG_Backend::pActiveBackendObject == nullptr || !MG_Backend::pActiveBackendObject->GetDynamicParameters().SupportsFloat64VertexAttributes; if (sourceVkFormat == VK_FORMAT_UNDEFINED && attr.Type == DataType::Float64 && narrowFloat64Arrays) { @@ -316,8 +456,14 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Invalidate every VAO's state-pointer memo: the erased node's // address may be reused by a future insert. Advance through the // process-wide source so the value stays unique across factory - // instances (see the member comment). + // instances (see the member comment). With no legacy arm the memos + // live in this factory and die with it, so a per-instance bump is + // enough - P2 D12.5. +#if MOBILEGL_PIPE_LEGACY_MEMOS m_evictionEpoch = ++s_evictionEpochSource; +#else + ++m_evictionEpoch; +#endif } else { ++it; } @@ -367,7 +513,17 @@ namespace MobileGL::MG_Backend::DirectVulkan { // demoted `vec` input - the same thing DirectGLES does for the same state. The // frontend RECORDS the format either way, so this gate is the only thing standing // between a legal glVertexAttribLFormat and a mismatched pipeline. - if (MG_Backend::pActiveBackendObject == nullptr || + if ( +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.7): see the narrowFloat64Arrays site above. + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? (MG_Remote::Server::ServerLoopInstance().Backend() == nullptr || + !MG_Remote::Server::ServerLoopInstance().Backend() + ->GetDynamicParameters() + .SupportsFloat64VertexAttributes) + : +#endif + MG_Backend::pActiveBackendObject == nullptr || !MG_Backend::pActiveBackendObject->GetDynamicParameters().SupportsFloat64VertexAttributes) { return VK_FORMAT_UNDEFINED; } diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h index b14231bbc..b93ad7719 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VertexInputStateFactory.h @@ -7,8 +7,12 @@ // End of Source File Header #pragma once +// MG_Pipe::MGPipeHandle for the P2 D12.5 memo table below. A header of constexpr constants, +// so the pull build gains nothing from it. +#include #include "Config.h" +#include "MagmaPipeArms.h" #include "VertexInputStateBuilder.h" #include "MG_State/GLState/VertexArrayState/VertexArrayObject.h" #include @@ -70,8 +74,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { }; }; +#if MOBILEGL_PIPE_PUSH + // The mint is the RENDERER's (MagmaPipeIdentityTables), not a process-global and not + // this factory's: VulkanRenderer::LookupVaoDrawMemo has to derive the same {slot, gen} + // for the same VAO, and a table that outlived the context it was minted for would share + // one reclamation clock across two live contexts (review v2 minor 4). + VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice, + MagmaPipeIdentityTables& identity): + m_config(config), m_physicalDevice(physicalDevice), m_identity(&identity) {} +#else VertexInputStateFactory(const VulkanRendererConfig& config, VkPhysicalDevice physicalDevice): m_config(config), m_physicalDevice(physicalDevice) {} +#endif ~VertexInputStateFactory() = default; VertexInputStateFactory(const VertexInputStateFactory&) = delete; @@ -86,6 +100,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Memoized ComputeHash: reuses the VAO's cached hash while its config version // is unchanged. Use this on per-draw paths. HashType GetOrComputeHash(const MG_State::GLState::VertexArrayObject& vao) const; +#if MOBILEGL_PIPE_PUSH + // The VAO's content hash IF it has already been memoized, without computing one. + // P2 D12.5: the three draw-path readers that used to ask the VAO object this + // question ask the factory instead, because that is where the memo lives once the + // frontend object stops carrying the backend's state. + Bool TryGetMemoizedHash(const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const; +#endif const BackendVertexInputState& GetOrCreateVertexInputState( const MG_State::GLState::VertexArrayObject& vao, HashType hash); const BackendVertexInputState& GetOrCreateVertexInputState(const MG_State::GLState::VertexArrayObject& vao); @@ -112,6 +133,44 @@ namespace MobileGL::MG_Backend::DirectVulkan { static VkFormat ToFloat32VertexFormat(Int componentCount); Bool SupportsVertexBufferFormat(VkFormat format) const; +#if MOBILEGL_PIPE_PUSH + // ---- P2 D12.5: the backend's memos, off the frontend VAO and into the backend ---- + // + // The two facts that used to live as `mutable` fields on VertexArrayObject + // (Get/SetBackendHashMemo and Get/SetBackendStateMemo), kept here instead, keyed on + // the VAO's {slot, gen} and guarded by exactly the same config version. A frontend + // state object holding the backend's raw pointer is what P2 retires: under split the + // backend is in another process and its cache entry has no address a client could + // store, so the memo has to live on the side that owns the pointee. + // + // The AUX memo is not carried over: its two words moved into VaoDrawMemo::layoutHash + // and layoutAuxMasks long ago and its getter has no live reader anywhere in the tree, + // so the handle arm simply stops writing it (D12.5 says delete rather than move). + struct VaoBackendMemos { + // Whose memos these are. The identity table can recycle a slot for a different + // VAO under LRU pressure, and the handle compare - Gen included - is what says + // the contents are this object's and not its predecessor's. + MG_Pipe::MGPipeHandle Owner = MG_Pipe::kMGPipeNullHandle; + Uint64 Hash = 0; + Uint32 HashConfigVersion = ~0u; + const void* State = nullptr; + Uint64 StateEpoch = 0; + Uint32 StateConfigVersion = ~0u; + }; + // Grow-on-demand (D12.4), one entry per slot the renderer's mint has ever handed + // out, and NO CAPACITY: these two memos had none before this package either - they + // were unbounded mutable fields on the VertexArrayObject itself - and re-introducing + // eviction here is what review v2 rejected. MagmaPipeSlotTable grows in chunks so an + // entry reference stays valid across the nested GetOrCreateVertexInputState call. + // 48 B per live VAO, reclaimed with the slot when the object goes idle. + mutable MagmaPipeSlotTable m_vaoMemos; + // The renderer's {slot, gen} mint (see the constructor). Never null under push. + MagmaPipeIdentityTables* m_identity = nullptr; + // The entry belonging to `vao`, claimed (and cleared) if the slot currently holds + // someone else's. + VaoBackendMemos& MemosFor(const MG_State::GLState::VertexArrayObject& vao) const; +#endif + const VulkanRendererConfig& m_config; VkPhysicalDevice m_physicalDevice = VK_NULL_HANDLE; // Values are heap-allocated: UnorderedMap is open-addressing, so INSERT @@ -130,15 +189,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { // stale memo. // // Drawn from a process-wide source, never a per-instance counter: the VAO - // memos outlive this factory (they live on pGLContext's VAOs, the renderer + // memos outlive this factory (they live on the frontend context's VAOs, the renderer // is destroyed and recreated on EGL surface release/re-create), so a fresh // factory restarting at a dead factory's epoch value would honor its // dangling entry pointers. The constructor takes a value strictly greater // than anything a predecessor ever stamped, so a dead factory's memo can // never compare equal here - the same never-reused idiom as the lifetime ids. // Single-threaded like the rest of the factory (renderer-thread only). + // + // P2 D12.5: the process-wide source is the LEGACY arm's need. It exists because the + // memos live on the frontend VAOs and therefore outlive the factory. The handle arm's + // memo table is owned by this factory and dies with it, so a per-instance counter is + // enough there and the epoch shrinks back to what it looks like it should be. +#if MOBILEGL_PIPE_LEGACY_MEMOS static inline Uint64 s_evictionEpochSource = 0; Uint64 m_evictionEpoch = ++s_evictionEpochSource; +#else + Uint64 m_evictionEpoch = 1; +#endif static inline XXH64_state_t* m_hashState = XXH64_createState(); }; } // namespace MobileGL::MG_Backend::DirectVulkan diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp index bc56ae2b6..facba7053 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkBufferManager.cpp @@ -10,6 +10,8 @@ #include "../DirectVulkan.h" #include "VulkanRenderer.h" +#include "MG_Util/Metrics/PipeStats.h" + namespace MobileGL::MG_Backend::DirectVulkan { namespace { constexpr VmaAllocationCreateFlags kResidentBufferAllocationFlags = @@ -237,8 +239,38 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool VkBufferManager::UploadTransient(BufferKind kind, Uint32 frameIndex, const void* data, VkDeviceSize size, VkDeviceSize alignment, BufferSlice& outSlice) { - (void)kind; - return m_transientUploadArena.Upload(frameIndex, data, size, alignment, outSlice); + if (!m_transientUploadArena.Upload(frameIndex, data, size, alignment, outSlice)) { + return false; + } + if (MG_Util::PipeStats::Enabled()) { + // The single chokepoint for Magma's per-draw staging. Uniform is deliberately + // absent: its bytes are counted by the caller, which is the only place that + // knows whether the payload is the default block (stage-ubo-global) or a named + // one repacked into the ring (stage-ubo-named), and counting here as well would + // double every uniform byte. + switch (kind) { + case BufferKind::Vertex: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient, + static_cast(size)); + break; + case BufferKind::Index: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndexClient, + static_cast(size)); + break; + case BufferKind::Indirect: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndirectCmd, + static_cast(size)); + break; + case BufferKind::TextureBuffer: + case BufferKind::ShaderStorage: + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + break; + case BufferKind::Uniform: + break; + } + } + return true; } Bool VkBufferManager::InitializeTransientArenas() { @@ -347,6 +379,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource.pendingFullUpload = true; return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } resource.pendingFullUpload = false; return true; } @@ -361,6 +396,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), 16, staging)) { return false; } + if (MG_Util::PipeStats::Enabled()) { + // The staging fill is the host copy; the vkCmdCopyBuffer below is the device + // half of the same bytes and is not counted twice. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } VkCommandBuffer commandBuffer = m_copyProvider->AcquireBufferCopyCommandBuffer(); if (commandBuffer == VK_NULL_HANDLE) { return false; @@ -430,6 +470,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (!resource->buffer.Upload(bufferObject.MappedData(), size, 0)) { MGLOG_E_ONCE("VkBufferManager::OnRespecify: in-place upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); } } @@ -455,6 +497,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), static_cast(offset))) { MGLOG_E_ONCE("VkBufferManager::OnSubData: host upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); } return; } @@ -513,6 +558,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { static_cast(size), static_cast(offset))) { MGLOG_E_ONCE("VkBufferManager::OnFlushMappedRange: host upload failed"); resource->pendingFullUpload = true; + } else if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); } return; } @@ -583,6 +631,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { const Uint8* seed = bufferObject.MappedData(); if (seed != nullptr) { resource->buffer.Upload(seed, size, 0); + if (MG_Util::PipeStats::Enabled()) { + // The one-time seed of a persistent map. Everything the app writes AFTER + // this goes straight through the mapping and is persistent-map-push + // territory (unwired, D4/D-B4), not this class. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } } resource->persistentMapped = true; resource->pendingFullUpload = false; @@ -631,6 +686,10 @@ namespace MobileGL::MG_Backend::DirectVulkan { resource->usageFlags = 0; return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, + static_cast(size)); + } resource->pendingFullUpload = false; } @@ -710,6 +769,9 @@ namespace MobileGL::MG_Backend::DirectVulkan { outSlice)) { return false; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer, static_cast(size)); + } resource->transientSlice = outSlice; resource->transientFrameSerial = m_frameSerial; resource->transientChangeSerial = changeSerial; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp index e9191dc04..744f1e85a 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkClearManager.cpp @@ -13,6 +13,7 @@ #include "VkTextureManager.h" #include "MG_State/GLState/Core.h" +#include #include "MG_Util/Converters/MGToStr/FramebufferEnumConverter.h" #include "MG_Util/Converters/MGToStr/TextureEnumConverter.h" @@ -54,7 +55,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { if (payload.colorEncoding != ClearColorEncoding::Float) return; // With GL_FRAMEBUFFER_SRGB enabled GL performs the encoding itself, so the driver doing it // is exactly right and there is nothing to undo. - if (MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)) return; + if (MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)) return; if (ResolveSrgbAttachmentWriteFormat(destinationFormat, false) == destinationFormat) return; // sRGB -> linear (GL 4.6 core 8.24), applied to the colour channels only: alpha is stored diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp index 7396fa2e7..3adaf82c8 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkRenderPassManager.cpp @@ -13,6 +13,7 @@ #include "MG_Util/Converters/MGToStr/FramebufferEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" #include "MG_Util/Metrics/TextureMetrics.h" +#include namespace MobileGL::MG_Backend::DirectVulkan { static Bool TryResolveSampleCountFlagBits(Int requestedSamples, VkSampleCountFlagBits& outSampleCount) { @@ -610,7 +611,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // sRGB attachments switch between their sRGB and UNORM-twin views with this // capability (ResolveSrgbAttachmentWriteFormat), changing the render pass formats. const Bool framebufferSrgbEnabled = - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); XXHASH_VERIFY(XXH64_update(m_hashState, &framebufferSrgbEnabled, sizeof(framebufferSrgbEnabled))); auto& drawBuffers = fbo.GetDrawBuffers(); XXHASH_VERIFY(XXH64_update(m_hashState, drawBuffers.data(), drawBuffers.size() * sizeof(drawBuffers[0]))); @@ -962,7 +963,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { const VkImageLayout trackedRbLayout = rbResource->layout; const Bool rbFramebufferSrgb = - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); const VkFormat rbAttachmentFormat = ResolveSrgbAttachmentWriteFormat(rbResource->format, rbFramebufferSrgb); rbDesc.flags = 0; @@ -1108,7 +1109,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { textureResources.emplace_back(textureResource); desc.format = ResolveSrgbAttachmentWriteFormat( textureResource->format, - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb)); attachmentSampleCount = textureResource->sampleCount; trackedColorLayout = textureResource->layout; trackedAttachmentLayouts.emplace_back(TrackedAttachmentLayoutInfo { diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp index 222e7cd90..474137922 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VkTextureManager.cpp @@ -11,8 +11,10 @@ #include "ProgramFactory.h" #include "MG_State/GLState/Core.h" +#include #include "MG_Util/Converters/MGToStr/TextureEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" +#include "MG_Util/Metrics/PipeStats.h" #include #include @@ -805,7 +807,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { // sampled-texture sync scan the entire alive-texture map per draw. if (aliveIt == m_aliveObjects.end()) { WeakPtr aliveTexture; - const auto& liveTexture = MG_State::pGLContext->GetTextureObject(texture.GetExternalIndex()); + const auto& liveTexture = MGB_CTX->GetTextureObject(texture.GetExternalIndex()); if (liveTexture && liveTexture.get() == &texture) { aliveTexture = liveTexture; } else { @@ -948,7 +950,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } const Bool framebufferSrgbEnabled = - MG_State::pGLContext->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); + MGB_CTX->IsCapabilityEnabled(MobileGL::CapabilityInput::FramebufferSrgb); const VkFormat baseAttachmentFormat = viewFormatOverride != VK_FORMAT_UNDEFINED ? viewFormatOverride : resource->format; const VkFormat attachmentFormat = @@ -1742,6 +1744,16 @@ namespace MobileGL::MG_Backend::DirectVulkan { Bool VkTextureManager::SyncTexture(MG_State::GLState::ITextureObject &texture, TextureResource &outResource) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (gt): Magma's texture sync still reads - and clears - the CLIENT's mip shadow: + // the dirty scan below, and UploadDirtyMipLevels' texel reads / region reads / + // MarkStorageDirty(false) clears. CONTRACT-P5C §2 migrated Espryt's sync and Magma's + // T5 writes, and left THIS path on the legacy arm; under the verb barrier and one + // address space the reads answer correctly, and the migration is P7's server-side + // sync. The scope is the debt's greppable form (MipmapStorage.h); outside it the + // layer-1 guard still aborts. + const MG_State::GLState::MGPipeTextureLegacyArmScope textureLegacyArm; +#endif // Cross-draw fast path: if the resource is already built and neither the texture's // pixel content (bumped in MarkStorageDirty), its SHAPE (bumped in BumpShapeVersion) // nor its params changed since the last sync, there is nothing to re-check or @@ -3150,6 +3162,32 @@ namespace MobileGL::MG_Backend::DirectVulkan { packBox(dst, item.regionLo, item.regionSize); } + if (MG_Util::PipeStats::Enabled()) { + // Same shape split as Espryt's: one union box per item, or one job per rect of + // a refined rect list. The box/rect decision is invisible to SSIM and is what + // the +6 ms/frame Mali cliff of section 7.3 was, so it is counted apart from + // the bytes. + Uint64 boxEmissions = 0; + Uint64 rectEmissions = 0; + Uint64 jobs = 0; + for (const auto& item : uploadItems) { + if (item.rects.empty()) { + ++boxEmissions; + jobs += isCombinedDepthStencil ? 2u : 1u; + } else { + ++rectEmissions; + jobs += static_cast(item.rects.size()); + } + } + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageTexture, + static_cast(stagingSize)); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadEmissions, + static_cast(uploadItems.size())); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadBoxEmissions, boxEmissions); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadRectEmissions, rectEmissions); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadJobs, jobs); + } + const VkImageAspectFlags aspectMask = GetAspectMaskForFormat(outResource.format); VkPipelineStageFlags uploadSrcStageMask = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; VkAccessFlags uploadSrcAccessMask = 0; diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp index b600511c2..b019c180e 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.cpp @@ -14,6 +14,11 @@ #include "VertexInputStateBuilder.h" #include "MG_State/GLState/Core.h" +#include +#if MOBILEGL_PIPE_PUSH +// P5c ev: the GPU-write announcement routes through the reverse channel (R2). +#include +#endif #include "MG_State/GLState/ProgramState/ProgramObject.h" #include "MG_State/GLState/ProgramState/ShaderObject.h" #include "MG_State/GLState/SamplerState/SamplerObject.h" @@ -27,10 +32,18 @@ #include "MG_Util/Converters/MGToVk/RenderStateEnumConverter.h" #include "MG_Util/Converters/MGToVk/TextureEnumConverter.h" #include "MG_Util/Math/HalfFloat.h" +#include "MG_Util/Metrics/PipeStats.h" #include "MG_Util/Metrics/TextureMetrics.h" #include "MG_Util/SelfTest/PrimitivesGeneratedNoXfbProbe.h" #include "MG_Util/Texture/PixelStoreProcessor.h" #include +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (T5 / tx): the server's staged-texture shadow GenerateMipmap defines its chain on. +#include +#include +// P5c (G6): the named-blit arm's endpoint resolution runs inside the frontend-keyed scope. +#include +#endif #include #include #include @@ -395,6 +408,101 @@ namespace MobileGL::MG_Backend::DirectVulkan { }; static DynamicStateShadow g_dynamicStateShadow; +#if MOBILEGL_PIPE_PUSH + // ---- D12.3: DynamicTailKey's inputs against the P2 chunk table ---- + // + // DynamicTailKey's inventory (declared above, one line per reader) is an exact, + // hand-maintained enumeration of what the six Apply* in the tail read. The P2 chunk table + // (MG_Pipe/MGPipeRenderStateSpans.h) is an independent, offsetof-derived statement of + // which bytes of RenderStateParameters are dynamic state. The two were written for + // different reasons, so making them check each other is free evidence: if a later chunk + // edit demotes or promotes one of these members, the mismatch is a BUILD BREAK here rather + // than a tail that silently stops being re-run when its input moves. + // + // The brief (P2 D12.3) expects every input to be dynamic; the tree says otherwise for + // exactly one, and the tree is right - see the ScissorTestEnabledMask note below. + // + // These assertions ARE D19's DynamicChunksCoverMagmasDynamicTailKey, in the only file this + // package owns. D19 names it as a case in MG_Test/Pipe/RenderStateSpansTest.cpp, which + // belongs to package A (C.5). INTEGRATOR: make sure the outcome is not "neither" - if + // package A did not land that case, this static_assert block is the whole gate, and if it + // did, the two are redundant on purpose and both should stay. + namespace { + // Is [begin, begin + size) covered entirely by DYNAMIC chunks? + constexpr Bool MagmaRenderStateRangeIsDynamic(SizeT begin, SizeT size) { + const SizeT end = begin + size; + for (SizeT i = 0; i < MG_Pipe::kMGPipeRenderStateChunkCount; ++i) { + const SizeT chunkBegin = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i]; + const SizeT chunkEnd = MG_Pipe::kMGPipeRenderStateChunkBoundaries[i + 1]; + if (end <= chunkBegin || begin >= chunkEnd) continue; // disjoint + if (MG_Pipe::MGPipeRenderStateChunkIsPipeline(i)) return false; + } + return true; + } + using MagmaTailRsp = RenderStateParameters; + +#define MAGMA_TAIL_INPUT_IS_DYNAMIC(Member) \ + static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, Member), \ + sizeof(MagmaTailRsp::Member)), \ + "ApplyDynamicDrawStateTail reads " #Member \ + ", which the P2 chunk table no longer calls dynamic state: a change to it would " \ + "move the pipeline version, not the parameters version, and the tail would stop " \ + "being re-run for it") + + // Viewports, DepthRanges and ScissorBoxes are asserted over the WHOLE array while the + // tail reads only element 0. That is deliberately stricter than the reader needs: the + // chunk table has no per-element granularity today, so an array that is dynamic at all + // is dynamic entirely, and asserting the whole of it says so. If a later phase ever + // splits a per-viewport chunk out, this is a build break by design - narrow the assert + // to element 0 then, and say why in the same commit. + MAGMA_TAIL_INPUT_IS_DYNAMIC(Viewports); // ApplyGLViewportState: Viewports[0] + MAGMA_TAIL_INPUT_IS_DYNAMIC(DepthRanges); // ApplyGLViewportState: DepthRanges[0] + MAGMA_TAIL_INPUT_IS_DYNAMIC(BlendColor); // ApplyBlendConstants + MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetFactor); // ApplyPolygonOffsetState + MAGMA_TAIL_INPUT_IS_DYNAMIC(PolygonOffsetUnits); // ApplyPolygonOffsetState + MAGMA_TAIL_INPUT_IS_DYNAMIC(LineWidth); // ApplyLineWidthState + MAGMA_TAIL_INPUT_IS_DYNAMIC(ScissorBoxes); // the scissor rect: ScissorBoxes[0] +#undef MAGMA_TAIL_INPUT_IS_DYNAMIC + + // ApplyStencilState reads three of the seven members of each face, and D6 splits + // StencilFaceState at sub-member granularity for exactly this reason: Ref, ValueMask + // and WriteMask are VK_DYNAMIC_STATE_STENCIL_{REFERENCE,COMPARE_MASK,WRITE_MASK}, while + // Func and the three ops are baked into the pipeline. Asserted per member, per face, + // because the split runs THROUGH the struct rather than around it. + constexpr SizeT kMagmaStencilFace1 = offsetof(MagmaTailRsp, StencilStates) + sizeof(StencilFaceState); +#define MAGMA_TAIL_STENCIL_IS_DYNAMIC(Member) \ + static_assert(MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, StencilStates) + \ + offsetof(StencilFaceState, Member), \ + sizeof(StencilFaceState::Member)), \ + "ApplyStencilState reads the FRONT face's " #Member " as dynamic state"); \ + static_assert(MagmaRenderStateRangeIsDynamic(kMagmaStencilFace1 + offsetof(StencilFaceState, Member), \ + sizeof(StencilFaceState::Member)), \ + "ApplyStencilState reads the BACK face's " #Member " as dynamic state") + + MAGMA_TAIL_STENCIL_IS_DYNAMIC(Ref); + MAGMA_TAIL_STENCIL_IS_DYNAMIC(ValueMask); + MAGMA_TAIL_STENCIL_IS_DYNAMIC(WriteMask); +#undef MAGMA_TAIL_STENCIL_IS_DYNAMIC + + // THE ONE INPUT THAT IS NOT DYNAMIC, and the brief's D12.3 says it should be. + // The tree wins, and it is right: the split's only rule is "a byte is pipeline state + // iff a public setter that calls BumpVersions() writes it", and ScissorTestEnabledMask + // is written by SetCapability(ScissorTest), which does. It sits in pipeline chunk P6 + // with the other capability bools. The tail reads it only to decide between the + // scissor box and a full-extent rect, and it is HARMLESS there for a reason worth + // stating: a pipeline-half write moves the pipeline version, and the pipeline version + // moves only together with the parameters version (BumpVersions bumps both), so the + // tail's version gate is invalidated by it just the same. A DYNAMIC member promoted + // into the pipeline half would break that direction, which is what the asserts above + // are for; this one is pinned in the opposite direction so that DEMOTING it - which + // would be a real G7 violation - is also a build break. + static_assert(!MagmaRenderStateRangeIsDynamic(offsetof(MagmaTailRsp, ScissorTestEnabledMask), + sizeof(MagmaTailRsp::ScissorTestEnabledMask)), + "ScissorTestEnabledMask is written by SetCapability(ScissorTest), which calls " + "BumpVersions(), so the chunk table must keep it in the pipeline half"); + } // namespace +#endif // MOBILEGL_PIPE_PUSH + static void ResetDynamicStateShadow() { g_dynamicStateShadow = {}; } @@ -459,12 +567,12 @@ namespace MobileGL::MG_Backend::DirectVulkan { // round trip), which is why the honest-but-lossy path was kept over widening every // default-framebuffer Y-flip/pre-transform helper to floats. See the KNOWN INFIDELITY // note in MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp. - const FloatVec4& stored = MG_State::pGLContext->GetViewportIndexed(index); + const FloatVec4& stored = MGB_CTX->GetViewportIndexed(index); const IntVec4 viewportState(static_cast(std::lround(stored.x())), static_cast(std::lround(stored.y())), static_cast(std::lround(stored.z())), static_cast(std::lround(stored.w()))); - const FloatVec2& depthRange = MG_State::pGLContext->GetDepthRangeIndexed(index); + const FloatVec2& depthRange = MGB_CTX->GetDepthRangeIndexed(index); const IntVec2 logicalExtent = isDefaultFramebuffer ? ResolveDefaultFramebufferLogicalExtent(preTransform, framebufferExtent) : framebufferExtent; @@ -519,7 +627,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyBlendConstants(VkCommandBuffer commandBuffer) { - const FloatVec4& blendColor = MG_State::pGLContext->GetBlendColor(); + const FloatVec4& blendColor = MGB_CTX->GetBlendColor(); const float blendConstants[4] = { blendColor.x(), blendColor.y(), @@ -552,8 +660,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyPolygonOffsetState(VkCommandBuffer commandBuffer) { - const Float constantFactor = MG_State::pGLContext->GetPolygonOffsetUnits(); - const Float slopeFactor = MG_State::pGLContext->GetPolygonOffsetFactor(); + const Float constantFactor = MGB_CTX->GetPolygonOffsetUnits(); + const Float slopeFactor = MGB_CTX->GetPolygonOffsetFactor(); auto& shadow = g_dynamicStateShadow; if (shadow.depthBiasValid && shadow.depthBiasConstantFactor == constantFactor && shadow.depthBiasSlopeFactor == slopeFactor) { @@ -566,7 +674,24 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyLineWidthState(VkCommandBuffer commandBuffer) { - Float lineWidth = MG_State::pGLContext->GetLineWidth(); + Float lineWidth = MGB_CTX->GetLineWidth(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.7): with an active transport the dynamic parameters are the + // SERVER's own backend's - the client caps mirror is client memory (rule E). Monolith + // reads the mirror as it always did. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + if (MG_Backend::BackendObject* server = MG_Remote::Server::ServerLoopInstance().Backend()) { + const auto& dynamicParameters = server->GetDynamicParameters(); + const Float minLineWidth = dynamicParameters.AliasedLineWidthRangeMin; + const Float maxLineWidth = dynamicParameters.AliasedLineWidthRangeMax; + if (lineWidth < minLineWidth) { + lineWidth = minLineWidth; + } else if (lineWidth > maxLineWidth) { + lineWidth = maxLineWidth; + } + } + } else +#endif if (MG_Backend::pActiveBackendObject != nullptr) { const auto& dynamicParameters = MG_Backend::pActiveBackendObject->GetDynamicParameters(); const Float minLineWidth = dynamicParameters.AliasedLineWidthRangeMin; @@ -645,8 +770,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void ApplyStencilState(VkCommandBuffer commandBuffer) { - const StencilFaceState& frontStencil = MG_State::pGLContext->GetStencilState(StencilFace::Front); - const StencilFaceState& backStencil = MG_State::pGLContext->GetStencilState(StencilFace::Back); + const StencilFaceState& frontStencil = MGB_CTX->GetStencilState(StencilFace::Front); + const StencilFaceState& backStencil = MGB_CTX->GetStencilState(StencilFace::Back); const Uint32 frontReference = static_cast(std::max(frontStencil.Ref, 0)); const Uint32 backReference = static_cast(std::max(backStencil.Ref, 0)); @@ -1239,11 +1364,11 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void RecordClearBufferError(const char* func, ErrorCode code, const char* message) { - MG_State::pGLContext->RecordError(code, MakeUnique("DirectVulkan", func, message)); + MGB_CTX->RecordError(code, MakeUnique("DirectVulkan", func, message)); } static void RecordTextureCopyError(const char* func, ErrorCode code, const char* message) { - MG_State::pGLContext->RecordError(code, MakeUnique("DirectVulkan", func, message)); + MGB_CTX->RecordError(code, MakeUnique("DirectVulkan", func, message)); } static Bool HasDistinctCompleteDepthStencilTextureAttachments( @@ -1299,7 +1424,7 @@ namespace MobileGL::MG_Backend::DirectVulkan { } static void RecordUnsupportedFramebufferError(const char* func) { - MG_State::pGLContext->RecordError( + MGB_CTX->RecordError( ErrorCode::InvalidFramebufferOperation, MakeUnique( "DirectVulkan", func, @@ -1511,6 +1636,52 @@ void main() { return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (T5 / tx): the split arm of EnsureGenerateMipmapStorageAllocated. Under an active + // transport the apply thread may not WRITE the client's level storage - AllocateStorage + // and MarkStorageDirty on a frontend TextureObjectMipmap are §6 layer-1 surfaces + // (CONTRACT-P5C §2.3) - so the generated chain is defined on the SERVER's staged-texture + // shadow instead, keyed by this renderer's own texture twin (the TextureResource, + // node-stable in VkTextureManager's map). Same levels, same extents - derived from the + // Vulkan-space base extent, whose depth is already 1 for every array target (layers + // live in arrayLayers there, so the fixed-component split the GL-space derivation + // needs is unnecessary here; these shadow extents answer in Vulkan space, which every + // consumer of a Magma-keyed entry shares) - and every generated level is marked + // dirty-in-shadow, because its texels are generated on the GPU and no byte answer + // exists on this side. The client's chain is left stale, which §2.3 rules CORRECT: the + // two readers that could observe the staleness are both named refusals under split. + // The upload-target list still comes from the frontend object - a shape READ, the P7 + // registry's residual, not one of the writes this arm exists to remove. + static Bool EnsureGenerateMipmapShadowAllocated(const VkTextureManager::TextureResource& resource, + Uint32 baseMipLevel, + const Vector& uploadTargets) { + if (resource.mipLevels <= baseMipLevel || uploadTargets.empty()) { + return false; + } + const IntVec3 storageBaseTexelSize = {static_cast(resource.extent.width), + static_cast(resource.extent.height), + static_cast(resource.depth)}; + const IntVec3 baseTexelSize = ComputeMipTexelSize(storageBaseTexelSize, baseMipLevel); + if (baseTexelSize.x() <= 0 || baseTexelSize.y() <= 0 || baseTexelSize.z() <= 0) { + return false; + } + const Uint32 requiredMipLevelCount = baseMipLevel + ComputeFullMipLevelCount(baseTexelSize); + auto& store = MG_Remote::Server::ServerStagedTexture(); + const Uint64 key = MG_Remote::Server::StagedTextureStore::KeyForTwinAddress(&resource); + for (const auto uploadTarget : uploadTargets) { + for (Uint32 level = baseMipLevel + 1; level < requiredMipLevelCount; ++level) { + // A level the shadow already tracks (an adopted base chain) keeps its bytes; + // the generation made the GPU newer than either, which the mark says. + store.NoteLevelDefined(key, static_cast(uploadTarget), static_cast(level), + ComputeMipTexelSize(storageBaseTexelSize, level)); + store.MarkLevelGpuDirty(key, static_cast(uploadTarget), static_cast(level), + true); + } + } + return true; + } +#endif + static VkImageLayout ResolveGenerateMipmapFinalLayout(VkImageAspectFlags aspectMask) { return (aspectMask & (VK_IMAGE_ASPECT_DEPTH_BIT | VK_IMAGE_ASPECT_STENCIL_BIT)) != 0 ? VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL @@ -2767,7 +2938,7 @@ void main() { // glReadPixels final conversion: GL_CLAMP_READ_COLOR defaults to GL_FIXED_ONLY, // clamping fixed-point (normalized) buffers to [0,1] - visible for SNORM reads. if (applyReadColorClamp && wideType == GL_FLOAT) { - const GLenum clampMode = MG_State::pGLContext->GetClampReadColor(); + const GLenum clampMode = MGB_CTX->GetClampReadColor(); const Bool clamp = clampMode == GL_TRUE || (clampMode == GL_FIXED_ONLY && !IsFloatingPointReadbackFormat(srcFormat)); if (clamp) { @@ -2984,7 +3155,7 @@ void main() { inline ProgramFactory::CompileOptionFlags GetShaderTransformFlags(VkSurfaceTransformFlagBitsKHR preTransform) { ProgramFactory::CompileOptionFlags flags = ProgramFactory::CompileOptionBit::PositionZRemap; const auto& currentDrawFBO = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (currentDrawFBO != nullptr && currentDrawFBO->IsDefaultFramebuffer()) { flags |= ProgramFactory::CompileOptionBit::PositionYFlip; // gl_FragCoord follows the same rule the default-framebuffer RECTANGLES follow @@ -3013,6 +3184,13 @@ void main() { } void VulkanRenderer::Initialize() { +#if MOBILEGL_PIPE_PUSH + // P2 D14, and it belongs HERE rather than on a draw: "a Track-H subsystem whose bit is + // clear is a STARTUP Fatal{PipeLegacyMemosDisabled}". Checks Magma's own bit only, and + // only once this backend is the one being brought up, so an Espryt-side bitmask cannot + // kill a Magma run and vice versa. + MagmaPipeValidateSubsystemConfiguration(); +#endif CreateInstance(); CreateSurface(); PickPhysicalDevice(); @@ -3192,7 +3370,12 @@ void main() { m_physicalDevice.properties.limits.minUniformBufferOffsetAlignment, m_config.MaxFramesInFlight, maxProgramBindings, kDescriptorSetsPerFrame, m_textureManager.get(), m_samplerManager.get()); MOBILEGL_ASSERT(succeeded, "UniformDescriptorBinder initialization failed."); +#if MOBILEGL_PIPE_PUSH + m_vertexInputStateFactory = + MakeUnique(m_config, m_physicalDevice.handle, m_pipeIdentity); +#else m_vertexInputStateFactory = MakeUnique(m_config, m_physicalDevice.handle); +#endif MOBILEGL_ASSERT(m_vertexInputStateFactory != nullptr, "VertexInputStateFactory creation failed."); // Prime the first frame so Render() always targets an acquired swapchain image. @@ -3266,8 +3449,9 @@ void main() { } m_vertexInputStateFactory.reset(); m_xfbCounterBuffer.Destroy(); - m_xfbCounterSlotByObject.clear(); - m_xfbNextCounterSlot = 0; + m_xfbCounterSlotOwner.fill(0); + m_xfbCounterSlotLastUse.fill(0); + m_xfbCounterSlotUseSerial = 0; m_xfbCountersValid.fill(false); m_xfbLastSeenGeneration.fill(0); if (m_occlusionQueryPool != VK_NULL_HANDLE) { @@ -3443,8 +3627,8 @@ void main() { // with restart off it is a legitimate index and excluding it would truncate the // converted stream by exactly that vertex. const Bool primitiveRestartActive = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestart) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PrimitiveRestartFixedIndex); const Uint32 restartSentinel = indexSize == 1 ? 0xFFu : indexSize == 2 ? 0xFFFFu : 0xFFFFFFFFu; Uint32 maxIndex = 0; Bool sawIndex = false; @@ -3539,6 +3723,76 @@ void main() { if (m_vaoDrawMemoTable.empty()) { m_vaoDrawMemoTable.resize(kVaoDrawMemoSlotCount); } +#if MOBILEGL_PIPE_PUSH + if (MagmaPipeAbaControlDefeatsIdentity()) { + // Negative control C (P2 brief D18), ahead of BOTH arms because it defeats the + // identity half of both keys at once: the legacy arm's (address, lifetime id) pair + // and the handle arm's {slot, gen}. Every VAO lands on one entry and the entry is + // handed back without an identity compare and WITHOUT being cleared - which is + // exactly what this table would do if a replacement object reproduced its dead + // predecessor's address, or reused its slot without the generation moving. + // + // Nothing else about the entry is relaxed: whether the resolved bindings it holds + // are then USED is still decided by TryBindResolvedVertexBindings' frame serial, + // content hash, active-attribute mask and slice epochs. That is what keeps the arm + // an assertion about identity rather than about the memo as a whole. + VaoDrawMemo& aliased = m_vaoDrawMemoTable[kMagmaPipeAbaControlSlotIndex]; + aliased.vaoKey = vao; + aliased.vaoLifetimeId = vao->GetLifetimeId(); + return &aliased; + } + // ---- P2 D12.4, the handle arm ---- + // + // The slot PICKS the entry, and the handle DECIDES whether the entry is this VAO's - + // the same division of labour the legacy arm below gives the address and the lifetime + // id, with two differences that are both improvements: + // + // * the slot is dense from 1, so below kVaoDrawMemoSlotCount live slots the map is a + // bijection and the two-way probe never collides at all, where an address hash + // collides by the birthday rule from the first few dozen VAOs; + // * the handle is an exact identity - Gen moves whenever a slot changes owner - so + // neither a deleted VAO's successor at the same heap address nor a VAO whose slot + // was recycled can match a predecessor's entry, even byte-identically configured. + // That is what makes the lifetime-id half of the legacy compare unnecessary here. + // + // The capacity and the victim rule are deliberately the base ref's, unchanged: this is + // the one memo of the three that HAD a capacity before P2, and an entry lost to a + // collision costs exactly what it cost then (one vertex-binding re-resolve). Above + // kVaoDrawMemoSlotCount live VAOs a set of two ways serves four slots, and degrades + // from there - never worse than the address-hashed table it replaces, which was already + // colliding. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + const MG_Pipe::MGPipeHandle handle = ResolveVaoHandle(*vao); + const Uint32 index = MagmaPipeSlotIndex(handle) & (kVaoDrawMemoSlotCount - 1u); + VaoDrawMemo& first = m_vaoDrawMemoTable[index]; + if (first.vaoHandle == handle) { + return &first; + } + VaoDrawMemo& second = m_vaoDrawMemoTable[index ^ 1u]; + if (second.vaoHandle == handle) { + return &second; + } + // Miss: recycle a slot. Prefer an unclaimed one; otherwise evict the entry whose + // bindings memo is older (its VAO is the one drawn less recently). + VaoDrawMemo* victim = &first; + if (!MG_Pipe::MGPipeHandleIsNull(first.vaoHandle) && + (MG_Pipe::MGPipeHandleIsNull(second.vaoHandle) || + second.bindings.frameSerial < first.bindings.frameSerial)) { + victim = &second; + } + victim->vaoHandle = handle; + victim->vaoKey = vao; + victim->vaoLifetimeId = vao->GetLifetimeId(); + victim->contentHash = 0; + victim->layoutFactsValid = false; + // Unmatchable until a resolve completes (same rule as the legacy arm: a bailed-out + // resolve must never leave stale contents matchable). + victim->bindings.frameSerial = 0; + victim->bindings.indexFrameSerial = 0; + victim->bindings.indexBuffer = nullptr; + return victim; + } +#endif // Multiplicative mix of the (16-byte-aligned) address; take high bits, they // carry the most entropy of a multiply. const Uint64 mixed = static_cast(reinterpret_cast(vao) >> 4) * 0x9E3779B97F4A7C15ull; @@ -3548,6 +3802,13 @@ void main() { // its own is recycled, and a slot matched on a recycled address hands the new VAO // the dead one's resolved bindings. const Uint64 lifetimeId = vao->GetLifetimeId(); + // Negative control C has NO consumer here. It is answered once, ahead of both arms, by + // the early return above, so a run that reaches this line has the knob off and the + // lifetime-id half of the compare is unconditional. A fourth consumer here would be a + // second site deciding the same question - what MagmaPipeAbaControlDefeatsIdentity + // exists to prevent - and a trap: narrow that early return later and this one would + // silently return to D18's retired semantics. If it is ever narrowed, ask the accessor + // here rather than re-reading MG_Config::Features. VaoDrawMemo& first = m_vaoDrawMemoTable[index]; if (first.vaoKey == vao && first.vaoLifetimeId == lifetimeId) { return &first; @@ -3616,7 +3877,7 @@ void main() { VaoDrawMemo* slot = nullptr; ResolvedVertexBindings* memo = nullptr; Uint64 vaoContentHash = 0; - const Bool vaoHashKnown = vao.GetBackendHashMemo(vaoContentHash); + const Bool vaoHashKnown = VaoContentHashIfKnown(vao, vaoContentHash); if (vaoHashKnown) { slot = LookupVaoDrawMemo(&vao); memo = &slot->bindings; @@ -3921,7 +4182,7 @@ void main() { } const auto glType = programObj.vertexInputTypes[location]; - const auto& currentValue = MG_State::pGLContext->GetCurrentVertexAttribute(location); + const auto& currentValue = MGB_CTX->GetCurrentVertexAttribute(location); VkFormat format = VK_FORMAT_UNDEFINED; const void* sourceData = nullptr; VkDeviceSize sourceSize = 0; @@ -4055,7 +4316,7 @@ void main() { Bool substituteRestart = false; // One bulk parameters fetch instead of up to three accessor calls per indexed // draw; all three inputs are pure reads of these fields. - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); if (rsp.PrimitiveRestartEnabled && !rsp.PrimitiveRestartFixedIndexEnabled) { const Uint32 restartIndex = rsp.PrimitiveRestartIndex; const Uint32 fixedMax = MG_Util::FixedRestartIndexForGLType(pIndexBufferView->indexType); @@ -4281,6 +4542,22 @@ void main() { } void VulkanRenderer::ShutdownBlitResources() { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §3.1's named exemption): the hidden blit program and samplers + // are FRONTEND objects the server backend created on the apply thread, and under an + // active transport they die here on that same thread. Their destructors run the client + // death helper, whose lifetime-id probe is the frontend-keyed registry family - it + // resolves to nothing (no handle was ever minted for these objects) and routes no + // delete, so the scope admits the probe as named debt rather than letting the guard + // Fatal at server teardown. P7 gives these resources storage that is not a frontend + // object. + // + // P5e (id), ruling 12: one of Magma's FOUR apply-thread allocator debts, and the scope + // is now named after that debt rather than after the frontend-keyed registry - Espryt's + // half of which P5e is retiring, while this one waits for P7. The exemption is keyed on + // a DirectVulkan server, which is what this file always is. + const MG_Pipe::MagmaP7AllocatorDebtScope magmaP7AllocatorDebt; +#endif m_blitResources = {}; } @@ -4355,6 +4632,13 @@ void main() { } void VulkanRenderer::ShutdownDepthMipmapResources() { +#if MOBILEGL_BUILD_DISAGGREGATED + // Same shape as ShutdownBlitResources above: the hidden depth-mipmap program is a + // frontend object created and destroyed by the server backend on the apply thread, and + // its destructor's lifetime-id probe is admitted here as named debt - Magma's, P7's to + // retire, which is what the scope's P5e name says (ruling 12). + const MG_Pipe::MagmaP7AllocatorDebtScope magmaP7AllocatorDebt; +#endif m_depthMipmapResources = {}; } @@ -4810,11 +5094,12 @@ void main() { Uint32 VulkanRenderer::ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const { constexpr Uint32 kFullCoverage = 0xffffffffu; if (rasterizationSamples == VK_SAMPLE_COUNT_1_BIT) return kFullCoverage; - if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::Multisample)) return kFullCoverage; - if (!MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleMask)) return kFullCoverage; - return MG_State::pGLContext->GetRenderStateParameters().SampleMaskValue; + if (!MGB_CTX->IsCapabilityEnabled(CapabilityInput::Multisample)) return kFullCoverage; + if (!MGB_CTX->IsCapabilityEnabled(CapabilityInput::SampleMask)) return kFullCoverage; + return MGB_CTX->GetRenderStateParameters().SampleMaskValue; } +#if MOBILEGL_PIPE_LEGACY_MEMOS Uint64 VulkanRenderer::ComputePipelineStateHash(Uint32 colorAttachmentCount, VkSampleCountFlagBits rasterizationSamples) const { // One bulk fetch instead of ~17 per-field accessor calls into MG_State: every @@ -4823,7 +5108,7 @@ void main() { // field (RenderState.cpp), so the hashed values are bit-identical. This runs on // every draw whose pipeline-state version moved (a per-draw GL_BLEND toggle), // where the accessor-call overhead dominated the hash itself. - const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& p = MGB_CTX->GetRenderStateParameters(); Uint64 capabilityBits = 0; capabilityBits |= p.CullFaceEnabled ? 1ull << 0 : 0; capabilityBits |= p.DepthTestEnabled ? 1ull << 1 : 0; @@ -4905,6 +5190,23 @@ void main() { } return hash; } +#endif // MOBILEGL_PIPE_LEGACY_MEMOS + +#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS + Uint64 VulkanRenderer::ComputePipelineSubsetStateHashFallback() const { + // The client's own hash, over the client's own definition of the pipeline subset - the + // seven pipeline chunks of the P2 chunk table, which is a strict SUPERSET of what + // ComputePipelineStateHash enumerated by hand. The render-pass facts it does not carry + // (colorAttachmentCount, the rasterization sample count, and through them the effective + // sample mask) are exactly the facts entry.renderPassHash separates, which is why the + // CSO handle can key this memo in the first place; this fallback inherits that argument + // unchanged. + // + // Only reached with no render-state CSO bound, and only in a build with no pre-handle + // arm to fall back to instead. + return MG_Pipe::MGPipeComputePipelineSubsetHash(MGB_CTX->GetRenderStateParameters()); + } +#endif // A program that runs a geometry shader AND captures transform feedback. Both halves are // link-time properties, so this is safe to fold into a pipeline keyed on the program hash. @@ -4935,7 +5237,7 @@ void main() { if (!(aspects & DrawSetupAspect::IndexBuffer) || pIndexBufferView == nullptr) { return false; } - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); if (rsp.PrimitiveRestartFixedIndexEnabled) { return true; } @@ -4979,7 +5281,26 @@ void main() { // the VALUE hash of that subset, never the version itself: the version is monotonic, so // per-draw state flips (GL_BLEND toggles) would otherwise miss entries the memo holds. // The version only guards recomputing the hash - unchanged version, unchanged bytes. - const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); + const Uint renderStateVersion = MGB_CTX->GetPipelineStateVersion(); +#if MOBILEGL_PIPE_PUSH + // P2 D12.1. Non-null means the client's render-state CSO handle is this draw's state + // key and the hash below is not computed at all; null means the pre-handle arm. The + // two arms' entries can never match each other: the handle arm stores hash 0 and a + // real handle, the legacy arm a real hash and the null handle, and the probe compares + // both components. + const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso(); + // Exactly one of the two state keys is live per draw, and the ternary short-circuits, + // so a draw on the handle arm neither hashes nor touches the fallback cache. + const Uint64 pipelineStateHash = + !MG_Pipe::MGPipeHandleIsNull(renderStateCso) + ? 0 + : ResolveFallbackPipelineStateHash(renderStateVersion, + renderPassEntry.colorAttachmentCount, + renderPassEntry.sampleCount); +#else + // THE PULL BUILD'S TEXT, statement for statement what the base ref has: G1 admits no + // resize of this function, and a helper the compiler merely inlines is not the same + // instruction schedule. if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || m_pipelineStateHashColorCount != renderPassEntry.colorAttachmentCount || m_pipelineStateHashSampleCount != renderPassEntry.sampleCount) { @@ -4991,17 +5312,31 @@ void main() { m_pipelineStateHashValid = true; } const Uint64 pipelineStateHash = m_pipelineStateHash; +#endif for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) { const PipelineMemoEntry& entry = m_pipelineMemo[i]; if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode && entry.programHash == programObj.hash && entry.vertexInputHash == vertexLayoutHash && entry.renderPassHash == renderPassHash && entry.pipelineStateHash == pipelineStateHash && +#if MOBILEGL_PIPE_PUSH + entry.renderStateCso == renderStateCso && +#endif entry.primitiveRestartEnable == primitiveRestartEnable && entry.transformFlags == transformFlags) { + if (MG_Util::PipeStats::Enabled()) { + // Gate 5 of section 2.3.1. On a hit this whole function cost the one + // GetPipelineStateVersion read above plus this value compare. + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaPipelineMemo, /*hit=*/true); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } return entry.pipeline; } } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaPipelineMemo, /*hit=*/false); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } // Shape gate. Behind the memo probe deliberately: only a pipeline that was created // successfully is ever memoized, so a program refused here can never be sitting in the @@ -5157,22 +5492,22 @@ void main() { syntheticVertexInputState.pNext = vis.state.pNext; pipelineVertexInputState = &syntheticVertexInputState; } - auto cullFaceEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::CullFace); - auto depthTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest); + auto cullFaceEnabled = MGB_CTX->IsCapabilityEnabled(CapabilityInput::CullFace); + auto depthTestEnabled = MGB_CTX->IsCapabilityEnabled(CapabilityInput::DepthTest); auto polygonOffsetFillEnabled = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::PolygonOffsetFill) && + MGB_CTX->IsCapabilityEnabled(CapabilityInput::PolygonOffsetFill) && DrawModeUsesPolygonFill(mode); auto rasterizerDiscardEnabled = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard); auto colorLogicOpEnabled = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ColorLogicOp) && m_logicOpFeatureEnabled; - auto stencilTestEnabled = MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::StencilTest); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::ColorLogicOp) && m_logicOpFeatureEnabled; + auto stencilTestEnabled = MGB_CTX->IsCapabilityEnabled(CapabilityInput::StencilTest); // A framebuffer without a depth (stencil) attachment behaves as if the depth // (stencil) test always passes and nothing is written - even when the bound // image is a packed depth-stencil texture attached through only one half. { const auto& gatingFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (gatingFbo != nullptr && !gatingFbo->IsDefaultFramebuffer()) { const auto& depthAtt = gatingFbo->GetAttachment(MobileGL::FramebufferAttachmentType::Depth); const auto& stencilAtt = gatingFbo->GetAttachment(MobileGL::FramebufferAttachmentType::Stencil); @@ -5184,10 +5519,10 @@ void main() { } } } - const StencilFaceState& frontStencil = MG_State::pGLContext->GetStencilState(StencilFace::Front); - const StencilFaceState& backStencil = MG_State::pGLContext->GetStencilState(StencilFace::Back); + const StencilFaceState& frontStencil = MGB_CTX->GetStencilState(StencilFace::Front); + const StencilFaceState& backStencil = MGB_CTX->GetStencilState(StencilFace::Back); const VkPolygonMode requestedPolygonMode = - MG_Util::ConvertPolygonModeToVkEnum(MG_State::pGLContext->GetPolygonModeFront()); + MG_Util::ConvertPolygonModeToVkEnum(MGB_CTX->GetPolygonModeFront()); // VK_POLYGON_MODE_LINE/_POINT require the fillModeNonSolid device feature; fall back to // VK_POLYGON_MODE_FILL when the device lacks it. const VkPolygonMode effectivePolygonMode = @@ -5242,6 +5577,24 @@ void main() { return VK_NULL_HANDLE; } + if (MG_Util::PipeStats::Enabled()) { + // THE payload-builder walk section 2.3.1 says only runs on a pipeline memo miss. + // Counted as a constant, and counted HERE rather than at the top of the walk: + // the list-topology primitive-restart refusal above returns VK_NULL_HANDLE after + // only ten of these reads have run, and a tally that fires before an early return + // is an OVER-count, which breaks the lower-bound contract every other tally keeps. + // + // The 15 are: the six capability reads (cull face, depth test, polygon offset + // fill, rasterizer discard, colour logic op, stencil test), the draw-FBO slot + // read that gates depth/stencil, the two stencil face states, the polygon mode, + // the min sample shading value, the patch vertex count, the depth mask, the depth + // func, and the second draw-FBO slot read below. The sample-shading CAPABILITY + // read is the one excluded: it sits behind && on m_sampleRateShadingFeatureEnabled + // and does not run on a device without the feature. The other conditional reads - + // the cull-mode ternary, the logic-op fetch, the two tessellation default-level + // reads - are excluded for the same reason, so this stays a LOWER bound. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 15); + } PipelineFactory::PipelineCreatePayload payload { .programHash = programObj.hash, .vertexInputHash = vertexLayoutHash, @@ -5254,18 +5607,18 @@ void main() { // driver's own rate. Both halves move the render state's PIPELINE version, so a cached // pipeline built at the old rate cannot be handed back for the new one. .sampleShadingEnable = m_sampleRateShadingFeatureEnabled && - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::SampleShading), - .minSampleShading = MG_State::pGLContext->GetMinSampleShadingValue(), + MGB_CTX->IsCapabilityEnabled(CapabilityInput::SampleShading), + .minSampleShading = MGB_CTX->GetMinSampleShadingValue(), // Word 1 keeps its all-ones initialiser: GL has no state for samples 32..63. .sampleMask = {ResolveEffectiveSampleMask(renderPassEntry.sampleCount), 0xffffffffu}, .subpass = 0, .topology = vkTopology, .primitiveRestartEnable = primitiveRestartEnabled, - .patchControlPoints = static_cast(MG_State::pGLContext->GetPatchVertices()), + .patchControlPoints = static_cast(MGB_CTX->GetPatchVertices()), .viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin), .polygonMode = effectivePolygonMode, .cullMode = cullFaceEnabled - ? MG_Util::ConvertCullFaceModeToVkEnum(MG_State::pGLContext->GetCullFaceMode(), invertClockwise) + ? MG_Util::ConvertCullFaceModeToVkEnum(MGB_CTX->GetCullFaceMode(), invertClockwise) : VK_CULL_MODE_NONE, .frontFace = VK_FRONT_FACE_CLOCKWISE, // Read the geometry stage off the program's own shader list rather than @@ -5279,13 +5632,13 @@ void main() { .provokingVertexMode = SelectProvokingVertexMode( vkTopology, ProgramCapturesXfbFromGeometryStage(program)), .depthTestEnable = depthTestEnabled, - .depthWriteEnable = depthTestEnabled && MG_State::pGLContext->GetDepthMask(), + .depthWriteEnable = depthTestEnabled && MGB_CTX->GetDepthMask(), .depthBiasEnable = polygonOffsetFillEnabled, .rasterizerDiscardEnable = rasterizerDiscardEnabled, .logicOpEnable = colorLogicOpEnabled, .stencilTestEnable = stencilTestEnabled, - .depthCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(MG_State::pGLContext->GetDepthFunc()), - .logicOp = MG_Util::ConvertLogicOperationToVkEnum(MG_State::pGLContext->GetLogicOp()), + .depthCompareOp = MG_Util::ConvertDepthTestFuncToVkEnum(MGB_CTX->GetDepthFunc()), + .logicOp = MG_Util::ConvertLogicOperationToVkEnum(MGB_CTX->GetLogicOp()), .frontStencilFailOp = MG_Util::ConvertStencilOperationToVkEnum(frontStencil.FailOp), .frontStencilPassOp = MG_Util::ConvertStencilOperationToVkEnum(frontStencil.PassDepthPassOp), .frontStencilDepthFailOp = MG_Util::ConvertStencilOperationToVkEnum(frontStencil.PassDepthFailOp), @@ -5321,8 +5674,8 @@ void main() { // handed back after the application changed them. if (programObj.needsPassthroughTessControl && programObj.passthroughTessControlEmulatable && vkTopology == VK_PRIMITIVE_TOPOLOGY_PATCH_LIST) { - const FloatVec4& defaultOuterLevel = MG_State::pGLContext->GetPatchDefaultOuterLevel(); - const FloatVec2& defaultInnerLevel = MG_State::pGLContext->GetPatchDefaultInnerLevel(); + const FloatVec4& defaultOuterLevel = MGB_CTX->GetPatchDefaultOuterLevel(); + const FloatVec2& defaultInnerLevel = MGB_CTX->GetPatchDefaultInnerLevel(); payload.passthroughTessControlKey = ProgramFactory::ComputePassthroughTessControlKey( payload.patchControlPoints, defaultOuterLevel, defaultInnerLevel, programObj.passthroughPerVertexMembers); @@ -5374,7 +5727,7 @@ void main() { "GetOrCreatePipeline: colorAttachmentCount=%u exceeds payload capacity", payload.colorAttachmentCount); const auto& drawFboBinding = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); MOBILEGL_ASSERT(drawFboBinding != nullptr, "GetOrCreatePipeline: draw framebuffer is null"); const Bool isDefaultDrawFbo = drawFboBinding->IsDefaultFramebuffer(); const auto& drawBuffers = drawFboBinding->GetDrawBuffers(); @@ -5402,14 +5755,14 @@ void main() { BlendFactor dstAlpha = BlendFactor::Zero; BlendEquation colorEquation = BlendEquation::Add; BlendEquation alphaEquation = BlendEquation::Add; - MG_State::pGLContext->GetBlendFuncIndexed(i, srcRGB, dstRGB, srcAlpha, dstAlpha); - MG_State::pGLContext->GetBlendEquationIndexed(i, colorEquation, alphaEquation); - const Bool blendEnabled = MG_State::pGLContext->IsCapabilityEnabledIndexed(CapabilityInput::Blend, i); + MGB_CTX->GetBlendFuncIndexed(i, srcRGB, dstRGB, srcAlpha, dstAlpha); + MGB_CTX->GetBlendEquationIndexed(i, colorEquation, alphaEquation); + const Bool blendEnabled = MGB_CTX->IsCapabilityEnabledIndexed(CapabilityInput::Blend, i); // Per-draw-buffer color write mask (glColorMaski). Divergent per-attachment masks require // the independentBlend device feature; when it is absent, fall back to draw buffer 0's // mask for every attachment (matching the non-indexed glColorMask broadcast). const BoolVec4 bufferMask = - MG_State::pGLContext->GetColorMaskIndexed(m_independentBlendFeatureEnabled ? i : 0); + MGB_CTX->GetColorMaskIndexed(m_independentBlendFeatureEnabled ? i : 0); VkColorComponentFlags attachmentColorWriteMask = static_cast( (bufferMask.r() ? VK_COLOR_COMPONENT_R_BIT : 0u) | (bufferMask.g() ? VK_COLOR_COMPONENT_G_BIT : 0u) | @@ -5637,6 +5990,9 @@ void main() { entry.vertexInputHash = vertexLayoutHash; entry.renderPassHash = renderPassHash; entry.pipelineStateHash = pipelineStateHash; +#if MOBILEGL_PIPE_PUSH + entry.renderStateCso = renderStateCso; +#endif entry.primitiveRestartEnable = primitiveRestartEnable; entry.transformFlags = transformFlags; entry.pipeline = pipeline; @@ -5825,7 +6181,7 @@ void main() { VkRect2D VulkanRenderer::ComputeGLScissorRect(Uint32 index, const IntVec2& extent, VkSurfaceTransformFlagBitsKHR preTransform, Bool isDefaultFbo) const { - const auto& parameters = MG_State::pGLContext->GetRenderStateParameters(); + const auto& parameters = MGB_CTX->GetRenderStateParameters(); if ((parameters.ScissorTestEnabledMask & (1u << index)) == 0) { VkRect2D full{}; full.offset = {0, 0}; @@ -5886,12 +6242,37 @@ void main() { // One compare for the whole tail: see the gate's declaration in // DynamicStateShadow for why (version, extent, default-FBO flag) pins every // input the six Apply* below read. - const Uint paramsVersion = MG_State::pGLContext->GetRenderStateParametersVersion(); + // + // P2 D12.3: this read is RE-SOURCED, not re-shaped. Under MOBILEGL_PIPE_PUSH the + // accessor no longer walks into GLContext's RenderState - it returns + // PipeInputs::m_renderStateParametersVersion, which the applier publishes from + // MGPDynamicState::Version (set_dynamic_state) and MGPBindRenderState::Version + // (bind_render_state). So the gate now reads what the client PUSHED. + // + // What it does NOT do, and the P2 brief expects it to, is stop moving on a + // pipeline-only change. The tree settles that against the brief: bind_render_state + // carries m_version too and the applier publishes it, and it has to - Espryt's + // SyncRenderState uses the very same counter as its all-state change detector and G5 + // forbids touching it, so a bind that rewrote the pipeline half while leaving the + // counter still would make Espryt skip re-syncing the blend state it just changed. + // The second-level DynamicTailKey compare below is therefore what actually absorbs a + // pipeline-only change, exactly as it did before P2: one key build, no vkCmd*. + const Uint paramsVersion = MGB_CTX->GetRenderStateParametersVersion(); if (shadow.dynamicTailValid && shadow.dynamicTailParamsVersion == paramsVersion && shadow.dynamicTailExtentX == extent.x() && shadow.dynamicTailExtentY == extent.y() && shadow.dynamicTailIsDefaultFbo == isDefaultFbo) { + // Gate 6 of section 2.3.1: one version read plus a four-integer compare. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDynamicTail, /*hit=*/true); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } return; } + if (MG_Util::PipeStats::Enabled()) { + // The version read above and the bulk parameter fetch that builds the value key. + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDynamicTail, /*hit=*/false); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 2); + } const VkSurfaceTransformFlagBitsKHR preTransform = m_swapchainObject.GetPreTransform(); // Second-level VALUE gate: the version moved, but RenderState's version counts // every parameter, most of which this tail never reads. Build the key over @@ -5900,7 +6281,7 @@ void main() { // re-derive the value its shadow already holds. DynamicStateShadow::DynamicTailKey key; { - const RenderStateParameters& p = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& p = MGB_CTX->GetRenderStateParameters(); // Viewport 0 and its depth range: ApplyGLViewportState reads exactly those two // (per-index state for indices > 0 is keyed separately, see multiViewportKey below). key.viewport[0] = p.Viewports[0].x(); @@ -5976,7 +6357,7 @@ void main() { MOBILEGL_ASSERT( [&] { const auto& fbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); return isDefaultFbo == (fbo != nullptr && fbo->IsDefaultFramebuffer()); }(), "GetBaseTransformFlagsRaw: isDefaultFbo does not match the bound draw framebuffer"); @@ -6000,7 +6381,7 @@ void main() { // Entry select: by the draw program's lifetime id, MRU first (the id pins // the entry; every other fact is re-guarded below, so probing a stale // entry can only decline, never serve stale state). - const auto& program = *MG_State::pGLContext->GetProgramForDraw(); + const auto& program = *MGB_CTX->GetProgramForDraw(); const Uint64 programLifetimeId = program.GetLifetimeId(); SetupDrawSnapshot* snapPtr = nullptr; { @@ -6048,7 +6429,7 @@ void main() { // captured draw after glBeginTransformFeedback would bind the undecorated // variant and silently capture nothing while the CPU bookkeeping advances. const Bool wantsXfbCapture = m_transformFeedbackFeatureEnabled && - MG_State::pGLContext->IsTransformFeedbackActive() && + MGB_CTX->IsTransformFeedbackActive() && program.GetTransformFeedbackVaryingCount() > 0; const Bool snapHasXfbCapture = static_cast(ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags) & @@ -6062,12 +6443,26 @@ void main() { // buffer binds (re-run every draw anyway). Declining here would send every // draw of a VAO-cycling stream (Minecraft chunk rendering) through the full // path, re-resolving descriptors and texture layouts nothing invalidated. - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); +#if MOBILEGL_PIPE_PUSH + // P2 D12.4: the handle replaces the (address, lifetime id) pair here too - one + // compare instead of two, and the same identity the VAO draw memo is keyed on, so + // the two cannot disagree about whether "the VAO moved". The config version stays: + // it answers a different question (did this same object's layout change). + const Bool vaoMoved = + MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput) + ? (!(ResolveVaoHandle(vao) == snap.vaoHandle) || + vao.GetConfigVersion() != snap.vaoConfigVersion) + : (static_cast(&vao) != snap.vao || + vao.GetLifetimeId() != snap.vaoLifetimeId || + vao.GetConfigVersion() != snap.vaoConfigVersion); +#else const Bool vaoMoved = static_cast(&vao) != snap.vao || vao.GetLifetimeId() != snap.vaoLifetimeId || vao.GetConfigVersion() != snap.vaoConfigVersion; +#endif const auto& drawFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (static_cast(drawFbo.get()) != snap.drawFbo || drawFbo->GetLifetimeId() != snap.drawFboLifetimeId || drawFbo->GetObjectVersion() != snap.fboVersion) { @@ -6078,8 +6473,8 @@ void main() { // back on what the snapshot already describes (a GL_BLEND toggle between // two draws, a redundant glBindSampler), and declining here sends every // such draw through the full SetupDraw. - const Uint renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); - const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint renderStateVersion = MGB_CTX->GetPipelineStateVersion(); + const Uint64 bindGeneration = MGB_CTX->GetTextureBindGeneration(); const Bool renderStateMoved = renderStateVersion != snap.renderStateVersion; const Bool bindsMoved = bindGeneration != snap.bindGeneration; if (renderStateMoved) { @@ -6087,7 +6482,7 @@ void main() { // flavor input (depth/stencil participation); a flip of that must take // the full path's pass selection. One bulk parameters fetch instead of // two capability-accessor calls; both are pure reads of the same fields. - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); const Bool drawUsesDepthStencil = rsp.DepthTestEnabled || rsp.StencilTestEnabled; if (drawUsesDepthStencil != snap.drawUsesDepthStencil) { return false; @@ -6153,7 +6548,7 @@ void main() { Uint64 auxMasks = 0; Bool factsKnown = false; Uint64 contentHash = 0; - if (vao.GetBackendHashMemo(contentHash)) { + if (VaoContentHashIfKnown(vao, contentHash)) { const VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao); if (vaoMemo->layoutFactsValid && vaoMemo->contentHash == contentHash) { vaoLayoutHash = vaoMemo->layoutHash; @@ -6170,7 +6565,7 @@ void main() { auxMasks = VertexInputStateFactory::PackVertexInputAuxMasks( vertexInputState.unsupportedAttribMask, vertexInputState.attributeLocationMask); Uint64 stampedHash = 0; - if (vao.GetBackendHashMemo(stampedHash)) { + if (VaoContentHashIfKnown(vao, stampedHash)) { VaoDrawMemo* vaoMemo = LookupVaoDrawMemo(&vao); vaoMemo->contentHash = stampedHash; vaoMemo->layoutHash = vaoLayoutHash; @@ -6261,7 +6656,7 @@ void main() { if (contentSum != snap.sampledContentSum || paramsSum != snap.sampledParamsSum) { return false; } - const Uint64 samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + const Uint64 samplingResolutionGeneration = MGB_CTX->GetSamplingResolutionGeneration(); if (samplingResolutionGeneration != snap.samplingResolutionGeneration) { // Decline, not re-arm: snap.resolvedTransformFlags bakes the // ExplicitLod0Sampling verdict, which reads the effective sampler's @@ -6291,6 +6686,18 @@ void main() { // what lets a per-draw GL_BLEND toggle alternate between two memo entries // instead of missing forever on a monotonic version. A miss falls through // to the full lookup. +#if MOBILEGL_PIPE_PUSH + // Same arm selector as GetOrCreatePipeline's probe (P2 D12.1); this site is the + // fast path's copy of it, and the two must key identically or the fast path would + // hand back a pipeline the full path would not have matched. + const MG_Pipe::MGPipeHandle renderStateCso = ResolveBoundRenderStateCso(); + const Uint64 pipelineStateHash = + !MG_Pipe::MGPipeHandleIsNull(renderStateCso) + ? 0 + : ResolveFallbackPipelineStateHash(renderStateVersion, snap.renderPassColorCount, + snap.renderPassSampleCount); +#else + // The pull build's text, statement for statement (see GetOrCreatePipeline). if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || m_pipelineStateHashColorCount != snap.renderPassColorCount || m_pipelineStateHashSampleCount != snap.renderPassSampleCount) { @@ -6301,6 +6708,8 @@ void main() { m_pipelineStateHashSampleCount = snap.renderPassSampleCount; m_pipelineStateHashValid = true; } + const Uint64 pipelineStateHash = m_pipelineStateHash; +#endif const auto memoTransformFlags = ProgramFactory::CompileOptionFlags(snap.resolvedTransformFlags); for (Uint32 i = 0; i < m_pipelineMemoCount; ++i) { @@ -6308,7 +6717,10 @@ void main() { if (entry.pipeline != VK_NULL_HANDLE && entry.mode == mode && entry.programHash == programObj.hash && entry.vertexInputHash == vaoLayoutHash && entry.renderPassHash == snap.renderPassHash && - entry.pipelineStateHash == m_pipelineStateHash && + entry.pipelineStateHash == pipelineStateHash && +#if MOBILEGL_PIPE_PUSH + entry.renderStateCso == renderStateCso && +#endif entry.primitiveRestartEnable == drawPrimitiveRestartEnable && entry.transformFlags == memoTransformFlags) { pipeline = entry.pipeline; @@ -6342,6 +6754,15 @@ void main() { snap.bindGeneration = bindGeneration; snap.vao = static_cast(&vao); snap.vaoLifetimeId = vao.GetLifetimeId(); +#if MOBILEGL_PIPE_PUSH + // Guarded by the SUBSYSTEM, not only by the build switch: with bit 6 clear the field + // is dead (vaoMoved takes the address/lifetime-id branch), and minting a handle for it + // would put this package's cost inside MOBILEGL_PIPE_PUSH=0 - the all-pull control arm + // D14 defines as reproducing P1 exactly, and the arm D.4.3's T2 is measured on. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + snap.vaoHandle = ResolveVaoHandle(vao); + } +#endif snap.vaoConfigVersion = vao.GetConfigVersion(); snap.vaoLayoutHash = vaoLayoutHash; snap.pipeline = pipeline; @@ -6365,6 +6786,15 @@ void main() { MOBILEGL_ASSERT(idxUploadOk, "SetupDraw fast path: failed to upload index buffer"); } ApplyDynamicDrawStateTail(frame, snap.renderPassExtent, snap.drawFboIsDefault, snap.viewportCount); + if (MG_Util::PipeStats::Enabled()) { + // The six accessor reads this function makes unconditionally on the path that + // reaches here: the draw program, the VAO, the draw-FBO slot, the pipeline + // state version, the texture bind generation and the sampling-resolution + // generation. The XFB-active probe is elided on a device without the feature + // and the parameter-block fetch only runs when the pipeline state version + // moved, so neither is counted (lower bound, as everywhere else). + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 6); + } return true; } @@ -6382,7 +6812,7 @@ void main() { // was cancelled has no usable optimized module - and on an in-place // SanitizeAndOptimizeBinary failure GetGeneratedSpirv() still holds the RAW // glslang words, which must never reach vkCreateShaderModule. Drop the draw. - const auto& drawProgram = *MG_State::pGLContext->GetProgramForDraw(); + const auto& drawProgram = *MGB_CTX->GetProgramForDraw(); if (!drawProgram.GetLinkStatus() || !drawProgram.GetSpirvStatus()) { MGLOG_D("SetupDraw skipped: program=%u is linked=%d spirv=%d", drawProgram.GetExternalIndex(), static_cast(drawProgram.GetLinkStatus()), @@ -6390,19 +6820,37 @@ void main() { return false; } } + if (MG_Util::PipeStats::Enabled()) { + // THE per-draw denominator for Magma, plus the draw-program read above. Placed + // here rather than inside TrySetupDrawFastPath because the fast path has 27 + // decline returns and one success return: counting the gate from the caller is + // the only shape that cannot miss one. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::Draws, 1); + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 1); + } if (TrySetupDrawFastPath(frame, mode, aspects, drawParams, pIndexBufferView)) { + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDrawFastPath, /*hit=*/true); + } return true; } + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::CountGate(MG_Util::PipeStats::Gate::MagmaDrawFastPath, /*hit=*/false); + // The three reads the full path makes immediately below (draw FBO, VAO, + // program). The accessor reads the declined fast path had already made before + // it turned back are NOT counted. + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3); + } const auto& drawFbo = - MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); + MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); if (drawFbo != nullptr && IsUnsupportedFramebufferForDirectVulkan(*drawFbo)) { // Nothing was mutated: other entries' per-probe guards (FBO identity + // version among them) stay authoritative, so none need invalidating. RecordUnsupportedFramebufferError(__func__); return false; } - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); - const auto& program = *MG_State::pGLContext->GetProgramForDraw(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); + const auto& program = *MGB_CTX->GetProgramForDraw(); // The fast path declined (or had no entry for this program): whatever THIS // program's entry saw may be stale, and the full path below mutates state as // it goes, so the entry must not stay matchable if that path fails mid-way. @@ -6442,7 +6890,7 @@ void main() { ProgramFactory::CompileOptionFlags transformFlags = ProgramFactory::CompileOptionFlags(GetBaseTransformFlagsRaw(drawFboIsDefault)); // Captured draws take the xfb-decorated program variant. - if (m_transformFeedbackFeatureEnabled && MG_State::pGLContext->IsTransformFeedbackActive() && + if (m_transformFeedbackFeatureEnabled && MGB_CTX->IsTransformFeedbackActive() && program.GetTransformFeedbackVaryingCount() > 0) { transformFlags |= ProgramFactory::CompileOptionBit::XfbCapture; } @@ -6456,13 +6904,13 @@ void main() { { const Uint64 lodProgramLifetimeId = program.GetLifetimeId(); const Uint32 lodProgramVersion = program.GetBackendStateVersion(); - const Uint64 lodBindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint64 lodBindGeneration = MGB_CTX->GetTextureBindGeneration(); // The probe also reads the EFFECTIVE sampler's filters/aniso/LOD range // (ProgramSamplesOnlySingleLevelTextures), and those setters bump ONLY the // sampling-resolution generation - not the texture params version the sum // below covers. Without this key a filter/aniso change would keep serving // the stale verdict. - const Uint64 lodSamplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + const Uint64 lodSamplingGeneration = MGB_CTX->GetSamplingResolutionGeneration(); Bool lodMemoHit = false; if (m_lastLodDecisionValid && m_lastSampledSetValid && m_lastLodProgramLifetimeId == lodProgramLifetimeId && @@ -6580,7 +7028,7 @@ void main() { { const Uint64 programLifetimeId = program.GetLifetimeId(); const Uint32 programVersion = program.GetBackendStateVersion(); - const Uint64 bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + const Uint64 bindGeneration = MGB_CTX->GetTextureBindGeneration(); // The bind generation alone stopped covering this set the moment ResolveSampledBinding // started asking SamplesAsIncompleteTexture: membership now depends on the effective // sampler PARAMETERS (MIN_FILTER decides whether the mip chain is read at all) and on @@ -6599,7 +7047,7 @@ void main() { // object a unit carries goes through TextureUnit::SetSamplerObject and moves the bind // generation instead. Same term the SetupDrawSnapshot fast path and the LOD memo // already carry. - const Uint64 samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + const Uint64 samplingGeneration = MGB_CTX->GetSamplingResolutionGeneration(); const Bool sampledSetUnchanged = m_lastSampledSetValid && m_lastSampledSetProgramLifetimeId == programLifetimeId && m_lastSampledSetProgramVersion == programVersion && @@ -6760,8 +7208,8 @@ void main() { // pass flavor (GL: a disabled depth/stencil test neither reads nor writes // its buffer). const Bool drawUsesDepthStencil = - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::DepthTest) || - MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::StencilTest); + MGB_CTX->IsCapabilityEnabled(CapabilityInput::DepthTest) || + MGB_CTX->IsCapabilityEnabled(CapabilityInput::StencilTest); auto* renderPassEntry = m_renderPassManager->GetOrCreateRenderPass(*drawFbo, m_imageIndexAcquired, drawUsesDepthStencil); // nullptr: the framebuffer has an attachment DirectVulkan cannot represent (a texture the @@ -6896,14 +7344,21 @@ void main() { snap.programVersion = program.GetBackendStateVersion(); snap.vao = &vao; snap.vaoLifetimeId = vao.GetLifetimeId(); +#if MOBILEGL_PIPE_PUSH + // Subsystem-guarded for the same reason as the other stamping site: the field + // is dead with bit 6 clear, and MOBILEGL_PIPE_PUSH=0 has to be P1 exactly. + if (MagmaPipeTrackHArmIsHandles(MG_Pipe::kMGPipeSubsystemMagmaVertexInput)) { + snap.vaoHandle = ResolveVaoHandle(vao); + } +#endif snap.vaoConfigVersion = vao.GetConfigVersion(); snap.drawFbo = drawFbo.get(); snap.drawFboLifetimeId = drawFbo->GetLifetimeId(); snap.fboVersion = drawFbo->GetObjectVersion(); snap.drawFboIsDefault = drawFboIsDefault; snap.viewportCount = ResolveDrawViewportCount(programObj.writesViewportIndexBuiltin); - snap.renderStateVersion = MG_State::pGLContext->GetPipelineStateVersion(); - snap.bindGeneration = MG_State::pGLContext->GetTextureBindGeneration(); + snap.renderStateVersion = MGB_CTX->GetPipelineStateVersion(); + snap.bindGeneration = MGB_CTX->GetTextureBindGeneration(); snap.baseTransformFlags = GetBaseTransformFlagsRaw(drawFboIsDefault); snap.resolvedTransformFlags = transformFlags.GetRaw(); snap.renderPassHash = nowActiveRenderPass->hash; @@ -6929,7 +7384,7 @@ void main() { snap.programObj = nullptr; snap.programFactoryEpoch = 0; } - snap.samplingResolutionGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration(); + snap.samplingResolutionGeneration = MGB_CTX->GetSamplingResolutionGeneration(); Uint64 snapContentSum = 0; Uint64 snapParamsSum = 0; // Per-entry copies of this draw's sampled set (the scratch vectors @@ -6966,7 +7421,7 @@ void main() { auto& frame = m_frameContext.GetCurrent(); // The DISPATCH accessor: with a pipeline bound this is its compute stage program // itself, never the graphics composite (which carries no compute stage at all). - const auto& program = *MG_State::pGLContext->GetProgramForDispatch(); + const auto& program = *MGB_CTX->GetProgramForDispatch(); if (!program.GetLinkStatus() || !program.GetSpirvStatus()) { MGLOG_E_ONCE("DispatchCompute skipped: program=%u has no optimized SPIR-V", program.GetExternalIndex()); @@ -7018,7 +7473,7 @@ void main() { m_textureManager->CollectGarbage(); auto& frame = m_frameContext.GetCurrent(); // See DispatchCompute: the dispatch accessor, not the draw one. - const auto& program = *MG_State::pGLContext->GetProgramForDispatch(); + const auto& program = *MGB_CTX->GetProgramForDispatch(); if (!program.GetLinkStatus() || !program.GetSpirvStatus()) { MGLOG_E_ONCE("DispatchComputeIndirect skipped: program=%u has no optimized SPIR-V", program.GetExternalIndex()); @@ -7062,7 +7517,7 @@ void main() { return; } - auto indirectBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject(); + auto indirectBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DispatchIndirect).GetBoundObject(); if (!indirectBuffer) { MGLOG_E_ONCE("DispatchComputeIndirect skipped: GL_DISPATCH_INDIRECT_BUFFER is not bound"); return; @@ -7136,10 +7591,10 @@ void main() { VkClearRect clearRect{}; clearRect.rect = framebuffer.IsDefaultFramebuffer() - ? MakeDefaultFramebufferScissorRect(MG_State::pGLContext->GetScissorBox(), + ? MakeDefaultFramebufferScissorRect(MGB_CTX->GetScissorBox(), renderPassEntry->extent, m_swapchainObject.GetPreTransform()) - : MakeClampedScissorRect(MG_State::pGLContext->GetScissorBox(), renderPassEntry->extent); + : MakeClampedScissorRect(MGB_CTX->GetScissorBox(), renderPassEntry->extent); clearRect.baseArrayLayer = 0; // GL 3.3 §4.4.7: clearing a layered framebuffer clears every layer. clearRect.layerCount = renderPassEntry->layers; @@ -7187,10 +7642,10 @@ void main() { return; } // GL 3.3 §3.1: when RASTERIZER_DISCARD is enabled, Clear and ClearBuffer* are ignored. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { return; } - auto* fbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); + auto* fbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); MOBILEGL_ASSERT(fbo, "VulkanRenderer::Clear: draw framebuffer not found (fbo == nullptr)"); if (IsUnsupportedFramebufferForDirectVulkan(*fbo)) { RecordUnsupportedFramebufferError(__func__); @@ -7198,16 +7653,16 @@ void main() { } ClearFramebufferPayload payload { - .color = MG_State::pGLContext->GetClearColor(), - .depth = MG_State::pGLContext->GetClearDepth(), - .stencil = MG_State::pGLContext->GetClearStencil() + .color = MGB_CTX->GetClearColor(), + .depth = MGB_CTX->GetClearDepth(), + .stencil = MGB_CTX->GetClearStencil() }; // A render-pass loadOp clear always covers the complete attachment, while // OpenGL glClear is clipped by GL_SCISSOR_TEST. Blaze3D relies on this for // GuiItemAtlas: animated items clear only their atlas slot before being // redrawn. Queueing that clear as a loadOp erases every cached static item. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { VkClearRect clearRect{}; switch (PrepareScissoredClear(*fbo, clearRect)) { case ScissoredClearPrep::NoOp: @@ -7230,7 +7685,7 @@ void main() { continue; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(drawBufferIndex); if (!colorMask.r() && !colorMask.g() && !colorMask.b() && !colorMask.a()) { continue; } @@ -7257,7 +7712,7 @@ void main() { } VkImageAspectFlags depthStencilAspects = 0; - if ((mask & GL_DEPTH_BUFFER_BIT) != 0 && MG_State::pGLContext->GetDepthMask()) { + if ((mask & GL_DEPTH_BUFFER_BIT) != 0 && MGB_CTX->GetDepthMask()) { const auto& depthAttachment = fbo->GetAttachment(FramebufferAttachmentType::Depth); if (depthAttachment.IsComplete()) { depthStencilAspects |= VK_IMAGE_ASPECT_DEPTH_BIT; @@ -7270,7 +7725,7 @@ void main() { // vkCmdClearAttachments writes every bit, so only a full (8-bit stencil) or // zero mask can be expressed; treat a partial mask like a partial color mask. const Uint32 stencilWriteMask = - MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) == 0xFFu) { depthStencilAspects |= VK_IMAGE_ASPECT_STENCIL_BIT; } else if (stencilWriteMask != 0) { @@ -7299,11 +7754,11 @@ void main() { // gating for the deferred path: drop fully-masked planes, warn on partial // masks vkCmdClear*/loadOp clears cannot express. GLbitfield deferredMask = mask; - if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0 && !MG_State::pGLContext->GetDepthMask()) { + if ((deferredMask & GL_DEPTH_BUFFER_BIT) != 0 && !MGB_CTX->GetDepthMask()) { deferredMask &= ~static_cast(GL_DEPTH_BUFFER_BIT); } if ((deferredMask & GL_STENCIL_BUFFER_BIT) != 0) { - const Uint32 stencilWriteMask = MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + const Uint32 stencilWriteMask = MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) != 0xFFu) { if (stencilWriteMask != 0) { MGLOG_W_ONCE("DirectVulkan: deferred glClear with a partial stencil write mask is not supported"); @@ -7319,7 +7774,7 @@ void main() { if (drawBuffers[drawBufferIndex] == FramebufferAttachmentType::None) { continue; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(drawBufferIndex); const Bool full = colorMask.r() && colorMask.g() && colorMask.b() && colorMask.a(); if (full) { anyFullMask = true; @@ -7340,7 +7795,7 @@ void main() { if (attachmentType == FramebufferAttachmentType::None) { continue; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(drawBufferIndex); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(drawBufferIndex); if (!(colorMask.r() && colorMask.g() && colorMask.b() && colorMask.a())) { continue; } @@ -7369,7 +7824,7 @@ void main() { const ClearAttachmentPayload& clearPayload) { m_clearManager->CollectGarbage(); // GL 3.3 §3.1: when RASTERIZER_DISCARD is enabled, Clear and ClearBuffer* are ignored. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::RasterizerDiscard)) { return; } if (IsUnsupportedFramebufferForDirectVulkan(framebuffer)) { @@ -7411,7 +7866,7 @@ void main() { } // GL 3.3 §4.2.3: ClearBuffer* is clipped by GL_SCISSOR_TEST exactly like Clear. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { VkClearRect clearRect{}; switch (PrepareScissoredClear(framebuffer, clearRect)) { case ScissoredClearPrep::NoOp: @@ -7442,9 +7897,9 @@ void main() { // GL 3.3 §4.2.3: ClearBuffer* honors the write masks like Clear. Deferred // clears cannot express partial masks; warn and skip those. - const auto depthClearAllowed = [&]() -> Bool { return MG_State::pGLContext->GetDepthMask(); }; + const auto depthClearAllowed = [&]() -> Bool { return MGB_CTX->GetDepthMask(); }; const auto stencilClearAllowed = [&]() -> Bool { - const Uint32 stencilWriteMask = MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + const Uint32 stencilWriteMask = MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) == 0xFFu) { return true; } @@ -7456,7 +7911,7 @@ void main() { switch (buffer) { case GL_COLOR: { - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(static_cast(drawbuffer)); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(static_cast(drawbuffer)); if (!colorMask.r() && !colorMask.g() && !colorMask.b() && !colorMask.a()) { return; } @@ -7513,7 +7968,7 @@ void main() { if (!attachment.IsComplete()) { return; } - const BoolVec4 colorMask = MG_State::pGLContext->GetColorMaskIndexed(static_cast(drawbuffer)); + const BoolVec4 colorMask = MGB_CTX->GetColorMaskIndexed(static_cast(drawbuffer)); if (!colorMask.r() && !colorMask.g() && !colorMask.b() && !colorMask.a()) { return; } @@ -7531,7 +7986,7 @@ void main() { MakeVkClearColorValue(clearPayload, ColorFormatLacksAlpha(colorTexture)); } else { VkImageAspectFlags aspects = 0; - if ((clearPayload.mask & GL_DEPTH_BUFFER_BIT) != 0 && MG_State::pGLContext->GetDepthMask() && + if ((clearPayload.mask & GL_DEPTH_BUFFER_BIT) != 0 && MGB_CTX->GetDepthMask() && framebuffer.GetAttachment(FramebufferAttachmentType::Depth).IsComplete()) { aspects |= VK_IMAGE_ASPECT_DEPTH_BIT; } @@ -7539,7 +7994,7 @@ void main() { framebuffer.GetAttachment(FramebufferAttachmentType::Stencil).IsComplete()) { // GL 3.3 §4.2.3: the clear is masked by the front stencil write mask (see Clear). const Uint32 stencilWriteMask = - MG_State::pGLContext->GetStencilState(StencilFace::Front).WriteMask; + MGB_CTX->GetStencilState(StencilFace::Front).WriteMask; if ((stencilWriteMask & 0xFFu) == 0xFFu) { aspects |= VK_IMAGE_ASPECT_STENCIL_BIT; } else if (stencilWriteMask != 0) { @@ -7558,7 +8013,7 @@ void main() { void VulkanRenderer::QueueClearBufferPayload(GLenum buffer, GLint drawbuffer, const ClearAttachmentPayload& clearPayload) { - auto* fbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); + auto* fbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject().get(); if (!fbo) { return; } @@ -8516,8 +8971,58 @@ void main() { void VulkanRenderer::BlitFramebuffer(GLint srcX0, GLint srcY0, GLint srcX1, GLint srcY1, GLint dstX0, GLint dstY0, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) { - auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); - auto drawFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (G6, CONTRACT-P5C §3.3/§5.4): MAGMA'S NAMED ARM. A blit record whose + // ReadFbo/DrawFbo are non-null is a glBlitNamedFramebuffer: both framebuffers resolve + // from the record's handles, and the sink is told the pair was consumed - a backend + // that leaves the flag clear has no named arm and the verb declines there, loudly. + // Magma has no FBO twin registry (it reads the frontend object wherever it syncs), so + // the resolution here is frontend-keyed - the handle was minted over the frontend + // object's lifetime id, and the two probes below (the client allocator's slot entry + // and the frontend context's framebuffer pool) are named debt inside the scope, the + // same shape as Espryt's StateForHandle arm: P7 retires it by carrying the object + // identity in the record. P5e (id), ruling 12: the third of Magma's four debts, so the + // scope it rides in is MagmaP7AllocatorDebtScope. Magma stays lockstep for the whole of + // P5e (CONTRACT-P5E §6.1), so the record being applied here is always barriered and the + // exemption still holds. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + auto& applierState = MG_Pipe::MGPipeApplier(); + const MG_Pipe::MGPipeHandle readHandle = applierState.VerbBlitReadFbo; + const MG_Pipe::MGPipeHandle drawHandle = applierState.VerbBlitDrawFbo; + if (!MG_Pipe::MGPipeHandleIsNull(readHandle) || !MG_Pipe::MGPipeHandleIsNull(drawHandle)) { + const auto resolveEndpoint = [](MG_Pipe::MGPipeHandle handle) + -> SharedPtr { + const MG_Pipe::MagmaP7AllocatorDebtScope magmaP7AllocatorDebt; + if (handle == MG_Pipe::kMGPipeDefaultFramebuffer) { + return MG_State::pGLContext ? MG_State::pGLContext->GetFramebufferObject(0) : nullptr; + } + if (!MG_Pipe::MGPipeSlots().IsLive(MG_Pipe::MGPipeKind::Framebuffer, handle)) { + return nullptr; + } + const Uint64 lifetimeId = + MG_Pipe::MGPipeSlots().LifetimeIdOfSlot(MG_Pipe::MGPipeKind::Framebuffer, handle.Slot); + if (lifetimeId == 0 || MG_State::pGLContext == nullptr) return nullptr; + return MG_State::pGLContext->FindFramebufferObjectByLifetimeId(lifetimeId); + }; + auto readFbo = resolveEndpoint(readHandle); + auto drawFbo = resolveEndpoint(drawHandle); + if (!readFbo || !drawFbo) { + // Leave the pair UNCONSUMED: the sink's decline is the loud answer a + // missing endpoint deserves, not a silent blit of whatever is bound. + MGLOG_E_ONCE("MGPipe: Magma's named blit could not resolve an endpoint (read {%u, %u}, draw " + "{%u, %u}) to a frontend framebuffer; the verb declines at the sink", + readHandle.Slot, readHandle.Gen, drawHandle.Slot, drawHandle.Gen); + return; + } + applierState.VerbBlitNamedConsumed = true; + BlitNamedFramebuffer(readFbo, drawFbo, srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, + dstX1, dstY1, mask, filter); + return; + } + } +#endif + auto readFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + auto drawFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Draw).GetBoundObject(); BlitNamedFramebuffer(readFbo, drawFbo, srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); } @@ -8549,8 +9054,8 @@ void main() { // The scissor test clips blit writes: intersect the destination rectangle with // the scissor box and shrink the source proportionally. - if (MG_State::pGLContext->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { - const IntVec4& scissor = MG_State::pGLContext->GetScissorBox(); + if (MGB_CTX->IsCapabilityEnabled(CapabilityInput::ScissorTest)) { + const IntVec4& scissor = MGB_CTX->GetScissorBox(); const auto clipAxis = [](GLint& d0, GLint& d1, GLint& s0, GLint& s1, GLint clipLo, GLint clipHi) -> Bool { const Bool dstFlipped = d1 < d0; GLint lo = dstFlipped ? d1 : d0; @@ -9144,7 +9649,7 @@ void main() { return; } - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit()); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(MGB_CTX->GetActiveTextureUnit()); auto destinationTexture = textureUnit.GetBindingSlot(textureTarget).GetBoundObject(); if (destinationTexture == nullptr) { RecordTextureCopyError(__func__, ErrorCode::InvalidOperation, @@ -9152,7 +9657,7 @@ void main() { return; } - auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + auto readFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (readFbo == nullptr) { RecordTextureCopyError(__func__, ErrorCode::InvalidOperation, "CopyTexSubImage2D requires a framebuffer bound to GL_READ_FRAMEBUFFER."); @@ -9863,7 +10368,7 @@ void main() { return; } - auto readFbo = MG_State::pGLContext->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); + auto readFbo = MGB_CTX->GetFramebufferBindingSlot(FramebufferTarget::Read).GetBoundObject(); if (readFbo == nullptr) { MGLOG_E_ONCE("DirectVulkan::ReadPixels skipped: no read framebuffer is bound"); return; @@ -10618,8 +11123,8 @@ void main() { // Store honoring the client pack state (single slice). const auto& pixelPackBufferObject = - MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); - const auto packParams = MG_State::pGLContext->GetPixelStoreParameters(false); + MGB_CTX->GetBufferBindingSlot(BufferTarget::PixelPack).GetBoundObject(); + const auto packParams = MGB_CTX->GetPixelStoreParameters(false); const SizeT rowPixels = static_cast(packParams.RowLength > 0 ? packParams.RowLength : width); const SizeT packAlignment = packParams.Alignment > 0 ? static_cast(packParams.Alignment) : 1; const SizeT dstRowStride = ((rowPixels * dstPixelBytes) + packAlignment - 1) / packAlignment * packAlignment; @@ -10649,7 +11154,7 @@ void main() { void VulkanRenderer::GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { const auto textureUploadTarget = MG_Util::ConvertGLEnumToTextureUploadTarget(target); const auto textureTarget = MG_Util::ConvertGLEnumToTextureTarget(target); - auto& activeUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit()); + auto& activeUnit = MGB_CTX->GetTextureUnitObject(MGB_CTX->GetActiveTextureUnit()); auto textureObject = activeUnit.GetBindingSlot(textureTarget).GetBoundObject(); GetTextureImage(textureObject, textureUploadTarget, level, format, type, -1, pixels); } @@ -10893,7 +11398,7 @@ void main() { return; } - auto& textureUnit = MG_State::pGLContext->GetTextureUnitObject(MG_State::pGLContext->GetActiveTextureUnit()); + auto& textureUnit = MGB_CTX->GetTextureUnitObject(MGB_CTX->GetActiveTextureUnit()); auto texture = textureUnit.GetBindingSlot(textureTarget).GetBoundObject(); MOBILEGL_ASSERT(texture != nullptr, "GenerateMipmap requires a bound texture."); MOBILEGL_ASSERT(texture->IsComplete(), "GenerateMipmap requires a complete texture."); @@ -10956,7 +11461,17 @@ void main() { "GenerateMipmap: depth-stencil mipmap generation is not supported yet."); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (T5 / tx): under an active transport the generated chain is defined on the + // server's staged shadow (keyed by the synced TextureResource above) and the client's + // level storage is never written; in monolith the client-object path runs unchanged. + const Bool allocatedMipmapStorage = + MG_Config::Transport != MG_Config::TransportMode::Monolith + ? EnsureGenerateMipmapShadowAllocated(*resource, baseMipLevel, texture->GetUploadTargets()) + : EnsureGenerateMipmapStorageAllocated(*mipmapTexture, baseMipLevel); +#else const Bool allocatedMipmapStorage = EnsureGenerateMipmapStorageAllocated(*mipmapTexture, baseMipLevel); +#endif MOBILEGL_ASSERT(allocatedMipmapStorage, "GenerateMipmap could not allocate a full mip chain for this texture."); resource = m_textureManager->SyncTextureAndGetDescriptor(*texture); @@ -11133,31 +11648,81 @@ void main() { } } + // Keyed on the frontend's never-reused lifetime id, NOT on the GL name. The name is + // recycled the moment glDeleteTransformFeedbacks gives it back, so a name-keyed slot + // handed a brand-new object the counter group - and the m_xfbCountersValid / + // m_xfbLastSeenGeneration entries - of the object that died under that name. + // + // Slots are never handed back (there is no backend entry telling this renderer that a span + // closed - registering the EndTransformFeedback one would flip the "captures through its own + // driver" test FixupGsStripCaptureOrder makes of it), so once all sixteen are owned a new + // object has to take one over. The victim is chosen among owners with NO OPEN SPAN: an object + // whose span is closed, or which no longer exists at all, can never resume, so its counter + // bytes are dead. Least-recently-used ALONE would be exactly the wrong rule - GL only permits + // another object to capture while this one is PAUSED, so the paused span whose counters the + // slots exist to protect is by construction the least recently used entry. Taking a group over + // resets its counter state, because those bytes describe the previous owner's span. Uint32 VulkanRenderer::CurrentXfbCounterSlot() { - const Uint name = MG_State::pGLContext->GetBoundTransformFeedbackName(); - const auto it = m_xfbCounterSlotByObject.find(name); - if (it != m_xfbCounterSlotByObject.end()) { - return it->second; + constexpr Uint32 kNoSlot = static_cast(kXfbCounterObjectSlots); + const Uint64 identity = MGB_CTX->GetBoundTransformFeedbackLifetimeId(); + MOBILEGL_ASSERT(identity != 0, + "transform feedback object reported the free-slot sentinel (0) as its identity - " + "every slot would then read as 'mine' without ever being claimed"); + Uint32 freeSlot = kNoSlot; + for (Uint32 slot = 0; slot < kNoSlot; ++slot) { + if (m_xfbCounterSlotOwner[slot] == identity) { + m_xfbCounterSlotLastUse[slot] = ++m_xfbCounterSlotUseSerial; + return slot; + } + if (m_xfbCounterSlotOwner[slot] == 0 && freeSlot == kNoSlot) { + freeSlot = slot; + } + } + Uint32 slot = freeSlot; + if (slot == kNoSlot) { + for (Uint32 candidate = 0; candidate < kNoSlot; ++candidate) { + if (MGB_CTX->HasOpenTransformFeedbackSpan(m_xfbCounterSlotOwner[candidate])) { + continue; + } + if (slot == kNoSlot || m_xfbCounterSlotLastUse[candidate] < m_xfbCounterSlotLastUse[slot]) { + slot = candidate; + } + } } - // Past the tracked set every object shares slot group 0. Only concurrently-paused - // spans need distinct groups, and applications do not keep sixteen of those open. - const Uint32 slot = m_xfbNextCounterSlot < kXfbCounterObjectSlots ? m_xfbNextCounterSlot++ : 0; - m_xfbCounterSlotByObject[name] = slot; + if (slot == kNoSlot) { + // Sixteen capture spans open at once. Whatever is taken loses its resume offset and + // restarts at byte 0 of its capture buffers, which is a wrong picture rather than a + // slow one - hence a report rather than a silent choice. + MGLOG_E_ONCE("CurrentXfbCounterSlot: all %zu counter groups belong to transform feedback objects " + "with an open capture span; the least recently used one is taken over and that span " + "will restart at offset 0 instead of appending", + kXfbCounterObjectSlots); + slot = 0; + for (Uint32 candidate = 1; candidate < kNoSlot; ++candidate) { + if (m_xfbCounterSlotLastUse[candidate] < m_xfbCounterSlotLastUse[slot]) { + slot = candidate; + } + } + } + m_xfbCounterSlotOwner[slot] = identity; + m_xfbCounterSlotLastUse[slot] = ++m_xfbCounterSlotUseSerial; + m_xfbCountersValid[slot] = false; + m_xfbLastSeenGeneration[slot] = 0; return slot; } Bool VulkanRenderer::BeginXfbCaptureForDraw(FrameContext::FrameData& frame) { - if (!m_transformFeedbackFeatureEnabled || MG_State::pGLContext == nullptr || - !MG_State::pGLContext->IsTransformFeedbackActive()) { + if (!m_transformFeedbackFeatureEnabled || !MGB_CTX_LIVE || + !MGB_CTX->IsTransformFeedbackActive()) { return false; } // A paused span captures nothing, and the counter buffers keep their values, so the // next resumed draw appends exactly where the last captured one stopped - which is // what pause/resume means (ARB_transform_feedback2). - if (MG_State::pGLContext->IsTransformFeedbackPaused()) { + if (MGB_CTX->IsTransformFeedbackPaused()) { return false; } - const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram(); + const auto& program = MGB_CTX->GetTransformFeedbackProgram(); if (!program || program->GetTransformFeedbackVaryingCount() == 0) { return false; } @@ -11196,7 +11761,7 @@ void main() { VkDeviceSize offsets[4] = {}; VkDeviceSize sizes[4] = {}; for (SizeT i = 0; i < bufferCount; ++i) { - auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::TransformFeedback, + auto& point = MGB_CTX->GetBufferBindingPoint(BufferTarget::TransformFeedback, static_cast(i)); const auto& bufferObject = point.GetBoundObject(); if (bufferObject == nullptr) { @@ -11207,7 +11772,13 @@ void main() { // have happened, so the buffer is also flagged for the wait that a later CPU // read has to perform - the capture is a GPU write like any shader's. bufferObject->EnsureGpuResidentStorage(); +#if MOBILEGL_PIPE_PUSH + // P5c ev (R2, CONTRACT-P5C §4.2): through the reverse channel, not a direct poke + // of the client object from the apply thread. + MG_Pipe::MGPipeAnnounceBufferGpuWritten(bufferObject); +#else bufferObject->MarkGpuWritten(); +#endif BufferSlice slice{}; if (!m_bufferManager.AcquireResidentSlice(BufferKind::Vertex, bufferObject, slice)) { MGLOG_E_ONCE("BeginXfbCaptureForDraw: failed to acquire capture buffer %zu", i); @@ -11227,7 +11798,7 @@ void main() { offsets, sizes); const Uint32 counterSlot = CurrentXfbCounterSlot(); - const Uint64 generation = MG_State::pGLContext->GetTransformFeedbackGeneration(); + const Uint64 generation = MGB_CTX->GetTransformFeedbackGeneration(); const Bool resume = m_xfbCountersValid[counterSlot] && m_xfbLastSeenGeneration[counterSlot] == generation; m_xfbLastSeenGeneration[counterSlot] = generation; @@ -11250,7 +11821,7 @@ void main() { if (!began) { return; } - const auto& program = MG_State::pGLContext->GetTransformFeedbackProgram(); + const auto& program = MGB_CTX->GetTransformFeedbackProgram(); const SizeT bufferCount = program ? std::min(program->GetTransformFeedbackBufferCount(), 4) : 0; const Uint32 counterSlot = CurrentXfbCounterSlot(); VkBuffer counterBuffers[4] = {}; @@ -11898,7 +12469,7 @@ void main() { default: break; } if (mergeGranularity != 0) { - const RenderStateParameters& rsp = MG_State::pGLContext->GetRenderStateParameters(); + const RenderStateParameters& rsp = MGB_CTX->GetRenderStateParameters(); if (rsp.PrimitiveRestartEnabled || rsp.PrimitiveRestartFixedIndexEnabled) { mergeGranularity = 0; } @@ -11976,7 +12547,7 @@ void main() { return; } - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); const auto* indexBuffer = vao.GetIndexBufferBindingSlot().GetBoundObject().get(); if (!indexBuffer) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: no element array buffer is bound"); @@ -11986,13 +12557,13 @@ void main() { const SizeT commandOffset = reinterpret_cast(indirect); const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(maxdrawcount - 1) + kGLDrawElementsIndirectCommandBytes; - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (!drawBuffer || commandBytes > drawBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); return; } - auto parameterBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); + auto parameterBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::Parameter).GetBoundObject(); if (!parameterBuffer || static_cast(drawcount) + sizeof(Uint32) > parameterBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawElementsIndirectCount skipped: invalid GL_PARAMETER_BUFFER binding or range"); return; @@ -12077,7 +12648,7 @@ void main() { return; } - const auto& vao = *MG_State::pGLContext->GetBoundVertexArray(); + const auto& vao = *MGB_CTX->GetBoundVertexArray(); const auto* indexBuffer = vao.GetIndexBufferBindingSlot().GetBoundObject().get(); if (!indexBuffer) { MGLOG_E_ONCE("MultiDrawElementsIndirect skipped: no element array buffer is bound"); @@ -12087,7 +12658,7 @@ void main() { const SizeT commandOffset = reinterpret_cast(indirect); const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(drawcount - 1) + kGLDrawElementsIndirectCommandBytes; - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (!drawBuffer || commandBytes > drawBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawElementsIndirect skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); return; @@ -12157,7 +12728,7 @@ void main() { const SizeT commandOffset = reinterpret_cast(indirect); const SizeT commandBytes = commandOffset + static_cast(stride) * static_cast(drawcount - 1) + kGLDrawArraysIndirectCommandBytes; - auto drawBuffer = MG_State::pGLContext->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); + auto drawBuffer = MGB_CTX->GetBufferBindingSlot(BufferTarget::DrawIndirect).GetBoundObject(); if (!drawBuffer || commandBytes > drawBuffer->GetSize()) { MGLOG_E_ONCE("MultiDrawArraysIndirect skipped: invalid GL_DRAW_INDIRECT_BUFFER binding or range"); return; @@ -12406,6 +12977,14 @@ void main() { if (m_vertexInputStateFactory) { m_vertexInputStateFactory->OnFrameBoundary(); } +#if MOBILEGL_PIPE_PUSH + // Reclaim {slot, gen} for objects that have not been drawn for a long time, on the same + // cadence and the same retirement age as the entries those slots key. This is the + // stand-in for the frontend death notification P2 has no hook for, and it is what keeps + // the mint's footprint the LIVE working set rather than every object ever created + // (review v2 MAJOR 1 / MAJOR 3). + m_pipeIdentity.OnFrameBoundary(); +#endif if (m_samplerManager) { m_samplerManager->OnFrameBoundary(); } @@ -12646,8 +13225,8 @@ void main() { if (!m_provokingVertexModePerPipeline) { return VK_PROVOKING_VERTEX_MODE_LAST_VERTEX_EXT; } - return (MG_State::pGLContext != nullptr && - MG_State::pGLContext->GetProvokingVertexMode() == ProvokingVertexMode::FirstVertex) + return (MGB_CTX_LIVE && + MGB_CTX->GetProvokingVertexMode() == ProvokingVertexMode::FirstVertex) ? VK_PROVOKING_VERTEX_MODE_FIRST_VERTEX_EXT : VK_PROVOKING_VERTEX_MODE_LAST_VERTEX_EXT; } @@ -12771,6 +13350,9 @@ void main() { InvalidateSetupDrawSnapshots(); } m_vertexInputStateFactory->OnFrameBoundary(); +#if MOBILEGL_PIPE_PUSH + m_pipeIdentity.OnFrameBoundary(); +#endif m_samplerManager->OnFrameBoundary(); auto& frame = m_frameContext.GetCurrent(); auto* activeRenderPass = VkRenderPassManager::GetActiveRenderPass(); diff --git a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h index c3d30855b..de43cb2cf 100644 --- a/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h +++ b/MobileGL/MG_Backend/DirectVulkan/Renderer/VulkanRenderer.h @@ -9,6 +9,7 @@ #pragma once #include "Config.h" #include "FrameContext.h" +#include "MagmaPipeArms.h" #include "PipelineFactory.h" #include "ProgramFactory.h" #include "SwapchainObject.h" @@ -24,7 +25,13 @@ #include "MG_Util/Math/VectorTypes.h" #include #include +#include #include +#if MOBILEGL_PIPE_PUSH +// The applier's CSO store: MGPipeApplier().BoundRenderStateCso is what the pipeline memo +// keys on after P2 (D12.1). Push-only, so the pull build's include graph is unchanged. +#include +#endif #include #include "../VkIncludes.h" @@ -675,8 +682,18 @@ namespace MobileGL::MG_Backend::DirectVulkan { // per object: one group of four slots each, handed out on first use. static constexpr SizeT kXfbCounterObjectSlots = 16; VkBufferObject m_xfbCounterBuffer; - UnorderedMap m_xfbCounterSlotByObject; - Uint32 m_xfbNextCounterSlot = 0; + // Which transform feedback object owns each slot group, by the frontend's never-reused + // lifetime id (0 = the slot is free). This used to be an UnorderedMap keyed on the GL + // NAME, which is recycled by glGenTransformFeedbacks: a deleted-and-recreated object + // inherited the dead one's slot, and since nothing ever removed an entry the map also + // grew for the life of the context. A fixed table cannot do either: a group is taken over + // only from an owner with no OPEN span (see CurrentXfbCounterSlot), so an object whose + // counters can still be resumed never loses them, and a dead object's group comes back. + Array m_xfbCounterSlotOwner{}; + // Tie-break among reclaimable groups only; never on its own, because the paused span the + // groups exist for is by construction the least recently used one. + Array m_xfbCounterSlotLastUse{}; + Uint64 m_xfbCounterSlotUseSerial = 0; // Set for a slot once a captured draw has been recorded into its span; selects // counter-buffer resume on the next captured draw of the same span. Array m_xfbCountersValid{}; @@ -810,12 +827,31 @@ namespace MobileGL::MG_Backend::DirectVulkan { Uint64 programHash = 0; Uint64 vertexInputHash = 0; Uint64 renderPassHash = 0; - // VALUE hash of the pipeline-relevant fixed-function state (see - // ComputePipelineStateHash), not the monotonic pipeline-state version: - // the version never repeats, so a per-draw GL_BLEND toggle would miss - // all entries forever even though the state alternates between two - // values the memo already holds. + // The PRE-HANDLE arm's key component (P2 brief D12.1), and 0 in every entry the + // handle arm mints. VALUE hash of the pipeline-relevant fixed-function state (see + // ComputePipelineStateHash), not the monotonic pipeline-state version: the version + // never repeats, so a per-draw GL_BLEND toggle would miss all entries forever even + // though the state alternates between two values the memo already holds. Uint64 pipelineStateHash = 0; +#if MOBILEGL_PIPE_PUSH + // The HANDLE arm's key component, and the whole of D12.1: the CLIENT already + // hashed the pipeline subset of RenderStateParameters and minted a content- + // addressed CSO for it (MG_Pipe/MGPipeRenderStateSpans.h, MG_Impl/Pipe/CsoCache), + // so re-hashing the same 396 bytes here was work the boundary had already done. + // Two draws share a CSO handle exactly when their pipeline bytes are equal, and + // the client's subset is a strict SUPERSET of what ComputePipelineStateHash read, + // so the handle discriminates at least as finely as the hash it replaces. + // + // renderPassHash STAYS beside it and is what keeps this key complete: the CSO + // carries GL state only, while colorAttachmentCount and the rasterization sample + // count - which ComputePipelineStateHash folded in through its signature and + // through ResolveEffectiveSampleMask - are render-pass facts that the render-pass + // hash already separates. + // + // Null in an entry minted by the legacy arm, so entries of the two arms can never + // match each other: the compare below tests BOTH components. + MG_Pipe::MGPipeHandle renderStateCso = MG_Pipe::kMGPipeNullHandle; +#endif ProgramFactory::CompileOptionFlags transformFlags = {}; // Baked into the pipeline (PipelineFactory::ComputeHash mixes it), and NOT derivable // from anything else in this key: it depends on whether the draw is indexed and on the @@ -829,19 +865,142 @@ namespace MobileGL::MG_Backend::DirectVulkan { PipelineMemoEntry m_pipelineMemo[kPipelineMemoSize]; Uint32 m_pipelineMemoCount = 0; Uint32 m_pipelineMemoNext = 0; - // Hash of every fixed-function GL state the pipeline payload reads that the - // memo key's other fields (mode / program / vertex input / render pass / - // transform flags) do not already pin down. Equal hash under an equal rest - // of key => byte-identical PipelineCreatePayload. Cached per pipeline-state + +#if MOBILEGL_PIPE_PUSH + // P2 D12.1's arm selector, and the whole of the pipeline memo's re-key. Returns the + // render-state CSO this draw is keyed on, or the null handle when the pre-handle arm + // is the one that runs. + // + // Under the handle arm the memo's state key IS this handle. The client hashed those + // 396 pipeline bytes when it minted the CSO (MGPipeComputePipelineSubsetHash), so + // recomputing an overlapping hash here was work the boundary had already done; the + // client's pipeline subset is a strict SUPERSET of what ComputePipelineStateHash read, + // so the handle discriminates at least as finely as the hash it replaces. What the + // handle does NOT carry is the render-pass side - colorAttachmentCount and the + // rasterization sample count, which ComputePipelineStateHash folded in through its + // signature and through ResolveEffectiveSampleMask - and that is exactly why + // entry.renderPassHash stays in the key beside it. + // + // The arm is live only when the render-state subsystem is migrated in this run AND the + // client has actually bound a CSO. The second half is not belt and braces: a tree whose + // tracker does not emit create/bind_render_state yet has no handle to key on, and + // delete_render_state clears the binding (MG_Pipe/PipeApply.cpp), so the null handle is + // reachable on any tree. Keying every draw on it would alias every render state onto + // one memo entry, so a null handle means "fall back to a state hash" - never an abort, + // and never a per-draw consultation of the legacy-memo lever: bit 0 is not a Track-H + // subsystem (D14 labels only bits 5 and 6 that), and the lever's Fatal is a STARTUP + // one, in MagmaPipeValidateSubsystemConfiguration. + // + // The fallback is warned ONCE rather than logged at debug, and that is deliberate: a + // silent fallback is what makes "the CSO arm never ran" easy to miss. W is compiled in + // at every shipped log level. + // + // The latch is a plain member bool, NOT MGLOG_W_ONCE. MOBILEGL_LOG_ONCE_INTERNAL + // (MG_Util/Debug/Log.h) is an UNCONDITIONAL std::atomic_flag::test_and_set - a locked + // xchg, executed on every evaluation, not "one static bool test" as an earlier round of + // this comment claimed - and this site is on the per-draw pipeline path in the very + // configuration that reaches it (no tracker: every draw). ROADMAP.md:7 forbids leaving + // instrumentation on a hot path, so the once-ness is one non-atomic, always-predicted + // load of a member that is false exactly once. Single-threaded like the rest of the + // renderer, and per renderer rather than per process, which is also the right scope: a + // second context that never binds a CSO deserves to say so. + // + // What the absence of this warning from a run's log proves, EXACTLY: that no draw took + // the fallback WHILE bit 0 was set. With kMGPipeSubsystemRenderState clear the function + // returns before the latch, so absence proves nothing at all - and no draw is keyed on a + // handle either. Grep the mask out of the log beside it (review v2 minor 3). + // + // Push-only by construction: the pull build does not compile this function at all, so + // its two callers are statement-for-statement what they were (G1). + // + // [routed to the integrator, review v2 minor 11] MG_Pipe::MGPipeApplier() is ONE + // process-global applier (MG_Pipe/PipeApply.cpp), not the per-context CSO store D2 + // specifies. In a multi-context process this reads whatever CSO another context last + // bound. The defect is package A's and the fix belongs there; Magma is its only P2 + // consumer, so it is named here rather than left for both reviews to assume the other + // caught it. + MG_Pipe::MGPipeHandle ResolveBoundRenderStateCso() const { + if (!MagmaPipeSubsystemOn(MG_Pipe::kMGPipeSubsystemRenderState)) { + return MG_Pipe::kMGPipeNullHandle; + } + const MG_Pipe::MGPipeHandle boundCso = MG_Pipe::MGPipeApplier().BoundRenderStateCso; + if (MG_Pipe::MGPipeHandleIsNull(boundCso) && !m_pipelineCsoFallbackWarned) { + m_pipelineCsoFallbackWarned = true; + MGLOG_W("MGPipe: kMGPipeSubsystemRenderState is on but no render-state CSO is " + "bound; the pipeline memo is running on a state hash, not on the CSO " + "handle (no tracker on this build, or a draw between " + "delete_render_state and the next bind)"); + } + return boundCso; + } + // Latch for the warning above. Mutable because the resolve is const and the latch is + // not part of the renderer's observable state. + mutable Bool m_pipelineCsoFallbackWarned = false; + // The memo key's STATE-HASH half, for a draw that has no CSO handle to key on: the + // pre-handle arm, and the fallback of D12.1's handle arm. Cached on the pipeline-state + // version plus the two render-pass facts the hash's inputs depend on, so an unchanged + // (version, colorAttachmentCount, sampleCount) proves the bytes are unchanged. + // + // [deviation from D12.1] The brief deletes this gate and its cached fields outright. + // They cannot go while a no-CSO draw is reachable - and it is, on any tree: a draw + // between delete_render_state and the next bind has no handle. On a tree whose tracker + // binds a CSO these five words are written once and never read again; they retire for + // real when the pull path does, at P13. + Uint64 ResolveFallbackPipelineStateHash(Uint renderStateVersion, Uint32 colorAttachmentCount, + VkSampleCountFlagBits rasterizationSamples) { + if (!m_pipelineStateHashValid || m_pipelineStateHashVersion != renderStateVersion || + m_pipelineStateHashColorCount != colorAttachmentCount || + m_pipelineStateHashSampleCount != rasterizationSamples) { +#if MOBILEGL_PIPE_LEGACY_MEMOS + m_pipelineStateHash = + ComputePipelineStateHash(colorAttachmentCount, rasterizationSamples); +#else + m_pipelineStateHash = ComputePipelineSubsetStateHashFallback(); +#endif + m_pipelineStateHashVersion = renderStateVersion; + m_pipelineStateHashColorCount = colorAttachmentCount; + m_pipelineStateHashSampleCount = rasterizationSamples; + m_pipelineStateHashValid = true; + } + return m_pipelineStateHash; + } +#endif // MOBILEGL_PIPE_PUSH +#if MOBILEGL_PIPE_PUSH && !MOBILEGL_PIPE_LEGACY_MEMOS + // The same answer as ComputePipelineStateHash, computed from the P2 chunk table + // instead of from a hand-written field list, for the build that compiles no + // pre-handle arm (cmake -DMOBILEGL_PIPE_LEGACY_MEMOS=OFF). It is the CLIENT's own + // hash function - MGPipeComputePipelineSubsetHash over the 396 pipeline bytes - so a + // draw keyed on it and a draw keyed on a CSO handle are keyed on the same equivalence + // class of state, and the render-pass facts stay separated by renderPassHash either + // way. This is what makes the no-legacy build RUNNABLE rather than a configuration + // that aborts on the first draw that arrives without a CSO. + Uint64 ComputePipelineSubsetStateHashFallback() const; +#endif +#if MOBILEGL_PIPE_LEGACY_MEMOS + // THE PRE-HANDLE ARM (P2 brief D12.1 / D14). Hash of every fixed-function GL state the + // pipeline payload reads that the memo key's other fields (mode / program / vertex + // input / render pass / transform flags) do not already pin down. Equal hash under an + // equal rest of key => byte-identical PipelineCreatePayload. Cached per pipeline-state // version: the version is monotonic and bumps on every pipeline-state // change, so an unchanged (version, colorAttachmentCount) proves the state // bytes are unchanged and the hash can be reused without re-reading them. + // + // The handle arm computes none of this: the client hashed the same bytes when it + // minted the CSO, so all five cached-hash members below exist only to avoid a + // re-hash the handle arm never performs. Uint64 ComputePipelineStateHash(Uint32 colorAttachmentCount, VkSampleCountFlagBits rasterizationSamples) const; +#endif // The effective GL_SAMPLE_MASK word for a draw at this rasterization sample count; see // the definition for the GL-vs-Vulkan rule it reconciles. Shared by the pipeline payload - // and the pipeline-state memo word so the two cannot disagree. + // and the pipeline-state memo word so the two cannot disagree. NOT part of the legacy + // arm: it is a PAYLOAD computation that depends on rasterizationSamples, so it survives + // the re-key and keeps reading Multisample / SampleMask / SampleMaskValue out of the + // working block. Uint32 ResolveEffectiveSampleMask(VkSampleCountFlagBits rasterizationSamples) const; + // ResolveFallbackPipelineStateHash's cache. Written once and never read again on a + // build whose client binds a render-state CSO; see that function for why it survives + // the re-key at all. Uint m_pipelineStateHashVersion = 0; Uint32 m_pipelineStateHashColorCount = 0; // The sample count the cached hash was computed at. A pipeline-state input now depends on @@ -867,7 +1026,8 @@ namespace MobileGL::MG_Backend::DirectVulkan { // Drops every memoized pipeline handle. Required at command-buffer // boundaries and whenever any pipeline may have been destroyed. Also drops // the cached pipeline-state hash: the same boundaries can retire the GL - // context whose monotonic version the cache is keyed on. + // context whose monotonic version the cache is keyed on. The handle arm has no + // such cache to drop - a CSO handle is not derived from a monotonic version. void InvalidatePipelineMemo() { m_pipelineMemoCount = 0; m_pipelineMemoNext = 0; @@ -958,6 +1118,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // common shape), and "the VAO did not move" would then skip the layout // re-resolve for a different VAO. Uint64 vaoLifetimeId = 0; +#if MOBILEGL_PIPE_PUSH + // P2 D12.4: the handle arm's answer to the same question, and one compare rather + // than the pair above. Kept BESIDE them rather than replacing them because the + // pre-handle arm is still compiled (MOBILEGL_PIPE_LEGACY_MEMOS) and this snapshot + // is a value struct, not a wire type. + MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle; +#endif Uint32 vaoConfigVersion = 0; const void* drawFbo = nullptr; // Never-reused lifetime id beside the raw pointer + Uint16 version: a @@ -1232,6 +1399,13 @@ namespace MobileGL::MG_Backend::DirectVulkan { // - bindings revalidates per draw exactly as before (frame serial, content // hash, per-binding live buffer pointers and slice epochs). struct alignas(64) VaoDrawMemo { +#if MOBILEGL_PIPE_PUSH + // P2 D12.4: the handle arm's key, and the ONLY key it needs. {slot, gen} is an + // identity, so the pointer-plus-lifetime-id pair below stops being a key here; + // the slot also picks the table entry, so the address hash and the two-way probe + // go with it. Null in an entry that has never been claimed. + MG_Pipe::MGPipeHandle vaoHandle = MG_Pipe::kMGPipeNullHandle; +#endif const MG_State::GLState::VertexArrayObject* vaoKey = nullptr; // The VAO's never-reused lifetime id, checked alongside vaoKey. The pointer // ALONE is not an identity: a deleted VAO's heap address is handed straight @@ -1258,8 +1432,48 @@ namespace MobileGL::MG_Backend::DirectVulkan { // fixed table also makes every VaoDrawMemo/ResolvedVertexBindings pointer // stable for the duration of a draw, which the EBO memo handoff // (m_currentDrawResolvedEntry) relies on. + // + // [deviation from D12.4, deliberate and narrow] The brief asks for a grow-on-demand + // Vector. This one stays FIXED at exactly the capacity and exactly the 2-way victim + // rule it has on the base ref, and only its KEY changes (a {slot, gen} handle instead + // of a hashed heap address plus a lifetime id). Two reasons, and the second is the + // whole of review v2's MAJOR 1: + // * a VaoDrawMemo is ~450 B (ResolvedVertexBindings dominates), so growing this + // table with the live VAO set is megabytes on a platform with an LMK, where the + // other two memos are 48 B and can afford it; + // * this is the ONLY one of the three memos that had a capacity before this package. + // Losing an entry here costs a vertex-binding re-resolve, exactly what losing it + // cost on the base ref, so at any working-set size this table is no worse than what + // it replaces - and strictly better below capacity, where the handle is a bijection + // with the slot and the two-way probe never collides at all. The other two memos + // (VertexInputStateFactory::m_vaoMemos) had NO capacity, so they keep having none. static constexpr Uint32 kVaoDrawMemoSlotCount = 2048; // power of two Vector m_vaoDrawMemoTable; +#if MOBILEGL_PIPE_PUSH + // The renderer's {slot, gen} mint, shared with its VertexInputStateFactory so both + // derive the same handle for the same VAO. Per renderer, never a process-global: a + // global would share one table and one reclamation clock across two live contexts and + // outlive every one of them (review v2 minor 4). + MagmaPipeIdentityTables m_pipeIdentity; + // The VAO's {slot, gen}. A one-entry memo hit for every acquisition after a draw's + // first, so there is no second memo in front of it here. + MG_Pipe::MGPipeHandle ResolveVaoHandle(const MG_State::GLState::VertexArrayObject& vao) { + return m_pipeIdentity.HandleOf(MG_Pipe::MGPipeKind::VertexElementsCso, + vao.GetLifetimeId()); + } +#endif + // "Is this VAO's content hash already memoized?", asked of whichever side owns the + // memo (P2 D12.5). Force-inlined and defined in the class body so that the PULL + // build's three readers keep compiling to the very same two loads they always did - + // G1 admits no resize, and an out-of-line call here would be one. + [[gnu::always_inline]] inline Bool VaoContentHashIfKnown( + const MG_State::GLState::VertexArrayObject& vao, Uint64& outHash) const { +#if MOBILEGL_PIPE_PUSH + return m_vertexInputStateFactory->TryGetMemoizedHash(vao, outHash); +#else + return vao.GetBackendHashMemo(outHash); +#endif + } // Finds the slot holding `vao`, or recycles the older of its two candidate // slots into an empty memo keyed on `vao`. Never returns null. VaoDrawMemo* LookupVaoDrawMemo(const MG_State::GLState::VertexArrayObject* vao); diff --git a/MobileGL/MG_Backend/Init.cpp b/MobileGL/MG_Backend/Init.cpp index fec433242..6d9470329 100644 --- a/MobileGL/MG_Backend/Init.cpp +++ b/MobileGL/MG_Backend/Init.cpp @@ -11,6 +11,25 @@ #include #include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#include +#include +#include +#endif + +#if MOBILEGL_BUILD_DISAGGREGATED +// [p5/v1-joint PREVIEW EDIT - the merge-time form of v1's hook, c1-v1 8.2's exact order.] The +// weak CreateRemoteBackendObject placeholder v1 shipped for a c1-less tree is DELETED at the +// merge: the integration test links the STATIC MobileGL_s archive, and an archive member is +// only pulled to satisfy an UNDEFINED reference - the weak definition in ServerLoop.cpp.o +// already satisfied it, so BackendObject_Remote.cpp.o was never linked at all and the joint +// build aborted in the placeholder (~/w7/p5-v1-joint-preflight.log). Direct construction +// needs no factory and no weak symbol. +#include +#endif + namespace MobileGL::MG_Backend { void LogBackendInfo() { if (!pActiveBackendObject) { @@ -45,9 +64,245 @@ namespace MobileGL::MG_Backend { return true; } +#if MOBILEGL_BUILD_DISAGGREGATED + namespace { + // WHICH MGPipe SUBSYSTEMS THIS SERVER HAS A CONSUMER FOR - CallMask bits 32..47 (R-8 / + // C-4). It is stated from what the server's own backend IS, and NOT derived from + // MGPipeGetResourceOps(): that is a PROCESS-WIDE global, so under inproc a derivation + // would answer with whatever the client half of the same process registered and under + // spawn it would collapse to P2's 0x7f. Either way CapsMirror::ServerConsumes would + // then answer a client-side liveness gate with a guess, the client would stop emitting + // whole record families, clear its dirty flags on acceptance anyway, and the lane would + // go green with the uploads lost - ID-39's 66 lost uploads, reflected (ServerSession.h). + // + // DirectGLES consumes all thirteen migrated families (P2's 0..6, P3a's 7..8, P4a's + // 9..12): it registers the resource op table in Initialize() + // (BackendObject_DirectGLES.cpp:849) and reads every other family out of gPipeInputs. + // DirectVulkan registers NO resource ops - MGPipeSetResourceOps has exactly one caller + // in the whole tree and it is Managers.cpp:2594 - so bit 7 is CLEAR for it, which is + // the same fact ObjectSubsystemControlScenario already pins from the client side. + // P5e (MG_Remote/CONTRACT-P5E.md §6.2). THE ONE CONSTANT THE WHOLE PHASE HANGS ON. + // + // kCapRunAheadApply says "this server applies an unbarriered record without reading + // client memory". That is only true once EVERY per-draw family reads records instead + // of the frontend - vi, sb, pg, tx2 and fb all land before it is - so the caps arm + // below is gated on this constant, which the P5e INTEGRATION COMMIT flips to true + // after the last of them. Until then every P5e package lands with the wait rule, the + // static WaitClass column and the barriered predicate compiled and INERT: the client + // never latches RunAheadArmed, so it runs today's lockstep path byte for byte. + // + // It is a constant and not a knob on purpose. An operator cannot turn a half-migrated + // server into a run-ahead one, because the failure mode is not a slow frame - it is + // the apply thread reading client memory that has already moved, which renders wrong + // rather than aborting. + constexpr Bool kMGPipeP5eRunAheadReady = true; + + // P5e (sb, ID-106). DirectGLES GAINS BIT 13 - the indexed buffer binding points - and + // Magma deliberately does not. c0e landed the phase constant and the dirty-bit map with + // this row still reading P4a's mask, because withholding a consumer bit is the safe + // direction while no emitter exists: the client's R-8 gate then keeps the whole family + // on the legacy pull path. The moment ShaderBufferEmit.h's wired constant leaves 0 the + // two have to move together, so they are one commit. + // + // DIRECTVULKAN STAYS ON P4a's MASK. The bit says "this server reads set_shader_buffers + // instead of walking the client's binding-point table", and Magma's UniformManager does + // no such thing - it has no binding-point records at all, and P5e leaves it lockstep + // (CONTRACT-P5E.md §6). Claiming the bit there would make the client emit a family + // nothing consumes, which is ID-39's failure with a different family's name on it. + Uint64 ConsumedSubsystemsFor(BackendType type) { + switch (type) { + case BackendType::DirectGLES: return MG_Pipe::kMGPipeSubsystemsMigratedAtP5e; + case BackendType::DirectVulkan: + return MG_Pipe::kMGPipeSubsystemsMigratedAtP4a & ~MG_Pipe::kMGPipeSubsystemResources; + default: return 0; + } + } + + // THE CROSS-CHECK THAT MAKES A WRONG ANSWER LOUD. Claiming bit 7 while no resource op + // table is registered is the exact failure the mask exists to prevent, one level down: + // the client would keep emitting the resource family and the server would drop every + // record of it. The check runs AFTER Initialize(), which is where DirectGLES registers + // the table, so it can see the real answer rather than a promise. + void AssertConsumerMaskIsHonest(Uint64 mask) { + const Bool claimsResources = (mask & MG_Pipe::kMGPipeSubsystemResources) != 0; + const Bool hasResourceOps = MG_Pipe::MGPipeGetResourceOps() != nullptr; + if (claimsResources && !hasResourceOps) { + MGLOG_F("MGPipe: Fatal{ConsumerMaskLie, \"kMGPipeSubsystemResources\"} - the " + "server published a consumer bit for the resource family while " + "MGPipeGetResourceOps() is null. The client's R-8 liveness gate would " + "keep emitting resource_create / resource_subdata records that this " + "server drops on the floor, and the client clears its dirty flags on " + "acceptance anyway (ID-39). A mask is a statement about this backend, " + "not a hope"); + std::abort(); + } + if (!claimsResources && hasResourceOps) { + // The safe direction: the legacy pull path keeps running. Said out loud anyway, + // because it silently costs the whole P3a family its migration. + MGLOG_W("MG_Remote server: a resource op table is registered but the consumer " + "mask withholds kMGPipeSubsystemResources; the buffer family will fall " + "back to the legacy path for this session"); + } + } + + // The resource op table the SERVER's backend registered at step 1 (BackendObject_DirectGLES:: + // Initialize -> RegisterBufferBackendOps), as step 2 saw it. Step 5 compares against it + // (review v2 N-8): a client object that registered a table of its own would have made + // AssertConsumerMaskIsHonest's "is a table registered" answer TRUE, so re-asking that + // question could never notice the swap - only the pointer can. + const MG_Pipe::MGPipeResourceOps* g_resourceOpsAtStep2 = nullptr; + + // The single hook (ARCHITECTURE.md:29). Returns false when the split could not be + // brought up, and the caller then REFUSES TO CONTINUE rather than falling back to the + // switch below - a fallback here is "the split lane ran monolith and went green". + Bool InitSplitRoles() { + using namespace MobileGL::MG_Remote; + + // 1. the SERVER role's private backend object, on the app thread, with no GL and no + // EGL. The context is created and made current later, on mgl-srv-apply, when the + // client's first eglMakeCurrent crosses as a blocking control request. + Server::ServerLoop& loop = Server::ServerLoopInstance(); + const MobileGLResult created = loop.CreateBackend(MG_Config::ActiveBackendType); + if (created != MOBILEGL_OK) return false; + + // 2. the two CallMask halves. NEITHER HAS A DEFAULT and CallMask() is a named Fatal + // on an unset one (s1's BLOCKER fix), so this is the "somebody" that block names. + Server::ServerSession& session = Server::ServerSessionInstance(); + const Uint64 consumed = ConsumedSubsystemsFor(MG_Config::ActiveBackendType); + AssertConsumerMaskIsHonest(consumed); + g_resourceOpsAtStep2 = MG_Pipe::MGPipeGetResourceOps(); + session.SetConsumedSubsystems(consumed); + // ZERO IS THE EXPLICIT ANSWER FOR P5, not an omission (ServerSession.h's block): + // every optional capability bit belongs to the package that owns its question, and + // withholding one leaves the legacy path running, which is the safe direction. + // kCapNeedsHostIndexBytes and kCapNeedsHostUboBytes must be 0 for the whole of P5 + // by ruling - they are the only two things that ask for an MGHostSpan, and 0 is + // what keeps every one of them out of the first IPC frame (contract table 0). + // + // P5b t2 (CONTRACT-P5B.md §6.5) PUBLISHES THE ONE BIT P5b ADDS, and this is the + // only place that can: the question kCapBackendOwnsXfbCapture answers is "does the + // SERVER's backend own the transform-feedback capture", and the server's table is + // visible here and nowhere on the client. It is read straight off the table + // ServerLoop::CreateBackend just built - Espryt registers XfbImpl::EndTransformFeedback + // (BackendObject_DirectGLES.cpp:1458) and Magma registers no XFB slot at all - so the + // bit is a statement about THIS backend rather than about a build option, which is + // what makes it survive a backend switch. The client reads it through + // MGL_BACKEND_SLOT_CAP at GL_Drawing.cpp's FixupGsStripCaptureOrder. + Uint64 capBits = 0; + if (const MG_Backend::BackendObject* serverBackend = loop.Backend(); + serverBackend != nullptr && + serverBackend->GetBackendFunctions().GL.EndTransformFeedback != nullptr) { + capBits |= MG_Pipe::kCapBackendOwnsXfbCapture; + } + // P5e (CONTRACT-P5E.md §1, §6): kCapRunAheadApply, THE DIRECTGLES ARM AND ONLY IT. + // + // Magma is deliberately absent and is not an omission: it keeps the lockstep for + // the whole of P5e, its four apply-thread allocator sites are real debt P7 retires, + // and MGPipeApplierCurrentRecordIsBarriered() answers true for every record on a + // server that does not publish this bit - which is exactly what keeps those probes + // and its BARRIER_PULLED reads inside P5C's semantics and its rsp accounting + // honest. Publishing the bit here for DirectVulkan would turn accounted pulls into + // torn ones, so MagmaPipeIdentityTest pins its absence. + // + // The Espryt arm is gated on kMGPipeP5eRunAheadReady, which is false until the + // integration commit: the bit is what ARMS the client, so every package before it + // lands inert. + capBits |= MG_Pipe::MGPipeRunAheadCapBitsFor(MG_Config::ActiveBackendType, + kMGPipeP5eRunAheadReady); + session.SetCapabilityBits(capBits); + session.SetBackend(loop.Backend()); + + // 3. the handshake, the four segments, and - at its end - the apply thread. + const MobileGLResult started = + Client::ClientSessionInstance().Start(MG_Config::Transport, MG_Config::TransportEndpoint); + if (started != MOBILEGL_OK) { + MGLOG_E("MG_Remote: the split session failed to start (rc=%d); MobileGL will not " + "fall back to monolith - a lane named split that ran monolith is the one " + "failure this phase is built to make impossible", + static_cast(started)); + return false; + } + + // 4. and only now the CLIENT's backend object in the one global that holds it. + // Table 3: pActiveBackendObject holds BackendObject_Remote and the server's + // BackendObject_DirectGLES stays private to ServerLoop. + pActiveBackendObject = MakeUnique(); + return true; + } + } // namespace +#endif + +#if MOBILEGL_BUILD_DISAGGREGATED + void ShutdownSplitRoles() { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + // ClientSession::Stop IS table 3's whole order and it is idempotent: publish and wait + // for the server to drain (bounded - a lost record must be a red lane, not a hung + // exit), Doorbell::Kill through the transport's Shutdown, ServerLoop::Stop's bounded + // join - which also destroys the server's private BackendObject ON the apply thread + // while it still owns the context - the transport, and only THEN anything an emitter + // owns. A var-tail still named by an unapplied record is a use-after-free the join is + // what prevents, which is why the order is not a preference. + MG_Remote::Client::ClientSessionInstance().Stop(); + // M-6: ClientSession::Stop's !m_started arm (a Start that FAILED after + // ServerSession::Accept - a refused Accept, an invalid cmd/reply ring) tears down only + // the client half and never stops the apply thread or drops the server's private backend, + // which ServerLoop::CreateBackend already built and which holds the process-wide + // g_resourceOps. So call ServerLoop::Stop() here unconditionally. It is idempotent: on the + // started path ClientSession::Stop already joined the thread, so this hits Stop's + // !joinable arm, which resets a backend that never ran a thread and is otherwise a no-op. + // Without this an early Start failure leaves BackendObject_DirectGLES permanently alive + // and every later split bring-up in the process fails at CreateBackend's m_backend!=null + // guard. + MG_Remote::Server::ServerLoopInstance().Stop(); + } +#endif + void Init() { MGLOG_D("Initializing MobileGL Backend..."); +#if MOBILEGL_BUILD_DISAGGREGATED + // THE SINGLE HOOK. In a build without MOBILEGL_BUILD_DISAGGREGATED, MG_Config::Transport + // is a `constexpr Monolith` (Config.h) and this whole statement is discarded, so the + // pull build gains no symbol, no branch and no byte - which is what G1 measures. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + if (!InitSplitRoles()) { + // NOT a fallback to the switch. pActiveBackendObject stays null and the next GL + // call fails loudly, which is the only honest outcome: the operator asked for a + // transport this process could not bring up. + pActiveBackendObject = nullptr; + return; + } + Bool remoteResult = InitSpecificBackendLibs(); + if (!remoteResult) { + MGLOG_W("Failed to initialize MobileGL backend libraries for the remote object"); + return; + } + // m-6, re-worded per review v2 N-8. The honesty cross-check runs a SECOND time, now + // that step 4's pActiveBackendObject (the client's BackendObject_Remote) exists and + // its Initialize() has run inside InitSpecificBackendLibs. What the re-run CAN catch + // is a table that was REMOVED between step 2 and here (the claim would then be a lie + // again). What it cannot catch - and its first comment claimed it could - is a client + // object that REGISTERED a table of its own: that leaves "is a table registered" + // true. Only the pointer tells those apart, so the table is compared against the one + // step 2 saw and a swap is refused by name: the applier would otherwise dispatch the + // server's resource records into the CLIENT object's table under its feet. + AssertConsumerMaskIsHonest(ConsumedSubsystemsFor(MG_Config::ActiveBackendType)); + if (MG_Pipe::MGPipeGetResourceOps() != g_resourceOpsAtStep2) { + MGLOG_F("MGPipe: Fatal{ConsumerMaskLie, \"resource ops table replaced\"} - the " + "resource op table MGPipeGetResourceOps() answers with is not the one the " + "server's backend registered at step 1 (%p now, %p then). Something between " + "ServerSession::Accept and the client object's Initialize() registered its " + "own table, and the applier would dispatch every resource record into it. A " + "mask is a statement about the server's backend, and so is the table", + static_cast(MG_Pipe::MGPipeGetResourceOps()), + static_cast(g_resourceOpsAtStep2)); + std::abort(); + } + LogBackendInfo(); + return; + } +#endif + switch (MG_Config::ActiveBackendType) { case BackendType::DirectGLES: pActiveBackendObject = MakeUnique(); diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp new file mode 100644 index 000000000..40d379382 --- /dev/null +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.cpp @@ -0,0 +1,515 @@ +// MobileGL - MobileGL/MG_Backend/MGPipe/PipeInputs.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The backend-side half of the PipeInputs block: the poison Fatal with its verb name, the +// name lookups the runtime knobs need, and - in a verify build - the per-field equality, +// the entry comparator and the corruption injector. Compiled only under MOBILEGL_PIPE_PUSH +// (CMakeLists.txt appends it to SOURCE_FILES there), so the pull build never sees it. Spells +// no MG_State global: everything that reads the live context lives in MG_Impl/Pipe/PipeFill.cpp. +#include + +#include +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#include +#endif + +namespace MobileGL::MG_Pipe { + const char* MGPipeVerbName(MGPipeVerb verb) { + const auto index = static_cast(verb); + return index < kMGPipeVerbCount ? kMGPipeVerbNames[index] : ""; + } + + [[noreturn]] void MGPipeInputPoisonFatalForVerb(MGPipeInputField field, MGPipeVerb verb) { + MGPipeInputPoisonFatal(field, MGPipeVerbName(verb)); + } + + Optional MGPipeFindInputField(const char* name) { + if (name == nullptr) return std::nullopt; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + if (std::strcmp(kMGPipeInputFieldNames[i], name) == 0) return static_cast(i); + } + return std::nullopt; + } + + Optional MGPipeFindVerb(const char* name) { + if (name == nullptr) return std::nullopt; + for (SizeT i = 0; i < kMGPipeVerbCount; ++i) { + if (std::strcmp(kMGPipeVerbNames[i], name) == 0) return static_cast(i); + } + return std::nullopt; + } + +#if MOBILEGL_BUILD_DISAGGREGATED + // ================================================================================ + // P5: the server's verb stamp, the residual-pull counter, and the four-way read verdict + // ================================================================================ + namespace { + Uint64 g_residualPulls = 0; + + // The strict arm of R-7.3. Same first line as the ordinary poison Fatal, so every + // existing filter on Fatal{UnmigratedPipeInput still matches, plus the class and the + // phase that retires it - a strict abort that did not say which phase owes the answer + // would leave the reader exactly where the gate found them. + // P5e (ra): `why` is the second half of the marker, because the lane's allowlist step + // reads this line and the two reasons mean different things to it. "MOBILEGL_IPC_ + // STRICT_ERRORS=1" is the operator asking for a real, ordered value to be loud; + // "UNBARRIERED" is the value not existing (§3.3). The `@` prefix and the + // Fatal{UnmigratedPipeInput tag are unchanged, so every filter written since P5c still + // matches. + [[noreturn]] void StrictBarrierPullFatal(MGPipeInputField field, MGPipeVerb verb, + const char* why) { + const SizeT index = static_cast(field); + MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, \"%s@%s\"} [BARRIER-PULLED, %s, " + "retires in %s]", + kMGPipeInputFieldNames[index], MGPipeVerbName(verb), why, + kMGPipeFieldRetiringPhase[index]); + std::abort(); + } + + // P5e (gl), ID-117: THE ADMITTED MARKER, AND WHY IT IS ONLY THE TAG THAT CHANGED. + // + // `@` and the `[BARRIER-PULLED, …, retires in ]` tail are IDENTICAL + // to the Fatal above; the leading tag is `Admitted{` instead of `Fatal{`. Every filter + // written since P5c matches `Fatal{UnmigratedPipeInput`, so all of them keep meaning + // exactly "red" and none of them has to learn a new grammar to keep meaning it. A reader + // grepping for the pair still finds it, and the lane can ratchet on both sets with one + // regex per tag (ID-119's two-sided ratchet). + // + // THE DEDUPE IS NOT TIDINESS. An 852-draw Minecraft frame reaches an admitted readback + // row once per draw; without this, one frame writes 852 identical lines, the log file is + // the size of the run and the marker census cannot be read at all. So it is once per + // (field, verb) per process, which is the granularity the allowlist is written in. + // + // A PLAIN ARRAY, NOT AN ATOMIC, and that is the file's existing rule rather than a + // shortcut: CountBarrierPull is reachable only after the server stamped a verb boundary, + // which happens on the apply thread inside PipeApplier::ApplyOne, and g_residualPulls + // beside it is a plain Uint64 for the same reason. A racing writer here would at worst + // log a duplicate line, never lose one. + void AdmittedBarrierPullOnce(MGPipeInputField field, MGPipeVerb verb, Bool escalated) { + const SizeT fieldIndex = static_cast(field); + const SizeT verbIndex = static_cast(verb); + if (fieldIndex >= kMGPipeInputFieldCount || verbIndex >= kMGPipeVerbCount) return; + static Uint64 seen[(kMGPipeVerbCount * kMGPipeInputFieldCount + 63) / 64] = {}; + const SizeT bit = verbIndex * kMGPipeInputFieldCount + fieldIndex; + const Uint64 mask = Uint64{1} << (bit % 64); + if ((seen[bit / 64] & mask) != 0) return; + seen[bit / 64] |= mask; + // P5e (gl), ID-128: WHICH DISJUNCT ADMITTED IT, in the slot the grammar already has + // for the reason. `ADMITTED` means the generated table said so, and the lane can + // check that against `--print-admitted`. `ADMITTED-ESCALATED` means the table did + // NOT and the record was barriered by an escalation the table cannot see, which is a + // RUNTIME fact about that record's payload - so the lane must not look for it in a + // static list. Saying which is what keeps the lane's comparison exact instead of + // widening the list with every pair that could ever escalate. + MGLOG_W("MGPipe: Admitted{UnmigratedPipeInput, \"%s@%s\"} [BARRIER-PULLED, %s, " + "retires in %s]", + kMGPipeInputFieldNames[fieldIndex], MGPipeVerbName(verb), + escalated ? "ADMITTED-ESCALATED" : "ADMITTED", + kMGPipeFieldRetiringPhase[fieldIndex]); + } + + // One place decides what a BARRIER-PULLED read does, so the field accessors and the + // seven sticky forwards cannot drift apart on it. + // + // ---- P5e (ra), CONTRACT-P5E §3.3: THE DETECTOR IS UNCONDITIONAL UNDER AN UNBARRIERED + // RECORD, AND THAT IS WHAT MAKES THE STRICT LANE A GATE ------------------------------- + // + // The knob exists because under LOCKSTEP a pulled row is a real, ordered, fresh value: + // the client filled it and then parked, so "count it and carry on" is an honest + // measurement of remaining debt and MOBILEGL_IPC_STRICT_ERRORS is the operator asking + // for the debt to be loud instead. None of that survives run-ahead. With the client + // running ahead of this apply, the row was either never filled for this verb (§3.1 + // skips the fill) or is being overwritten by a verb two frames later - so the value is + // torn or stale BY CONSTRUCTION and there is nothing for a counter to count. A + // "count it" arm here would be a wrong picture with a number beside it. + // + // P5e (gl), ID-119: THE OLD PIN HERE - "rsp is 0 on unbarriered records" - WAS VACUOUS + // AND HAS BEEN WITHDRAWN. The unbarriered arm below is [[noreturn]] and runs BEFORE + // ++g_residualPulls, so an unbarriered pull can never reach the counter whatever this + // function is written to do; the sentence was true of every possible implementation and + // therefore checked nothing. What rsp actually counts is BARRIERED pulls, and under the + // strict knob it counts exactly the ADMITTED ones, because every other barriered pull + // aborts two lines down. That is the form CONTRACT-P5E §7 now states and the lane checks. + // + // ---- P5e (gl), ID-117: AN ADMITTED PULL IS LOUD, NOT FATAL --------------------------- + // + // Before this, strict aborted on EVERY barrier-pulled read, admitted or not - which made + // the CI's allowlist comparison unreachable code (the run's rc != 0 exits first) and made + // "hard green" impossible with the knob as written. So there is a third state, and the + // two existing ones are untouched: + // + // unbarriered -> Fatal, no knob. The value is torn by construction. + // barriered, unadmitted -> Fatal under strict. A debt no phase has taken. + // barriered, admitted -> ONE MGLOG_W per (field, verb), and the entry completes. + // + // "Admitted" has three disjuncts and the third is a runtime one (ID-128): the generated + // table answers the first two, and the record's own escalation flag answers the third. + // + // An admitted pull is a debt this phase deliberately leaves standing: the field's row is + // BARRIER_PULLED, the verb's op is statically barriered so the client really is parked + // behind the record, and the field is inside the verb's own may-read mask - so the value + // is real, ordered and fresh, exactly ruling 4's "barriered records keep P5C semantics". + // MGPipeBarrierPullAdmitted is generated from those three tables (ID-116); there is no + // list here to drift. + void CountBarrierPull(MGPipeInputField field, MGPipeVerb verb) { + if (!MGPipeApplierCurrentRecordIsBarriered()) { + StrictBarrierPullFatal(field, verb, "UNBARRIERED, the client did not fill it"); + } + ++g_residualPulls; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ResidualPulls, 1); + } + if (MG_Config::Ipc.StrictErrors) { + // THE THIRD DISJUNCT (P5e gl, ID-128), and it has to be asked at RUNTIME because + // it is a fact about this record's payload rather than about its opcode. The + // static table admits a pair when the verb's op waits (disjunct 1) or when the + // field's retiring phase is not this phase (disjunct 2); an escalated record is + // one the client parks behind for a reason only the payload knows - an open + // transform-feedback span, or a draw carrying client vertex arrays. Both are + // outside this phase by ruling (§5.7, ID-82), and the client-array read site + // aborts by its own name if it is ever applied unbarriered, so the pull is legal + // and P5e is not the phase that owes it. + const Bool statically = MGPipeBarrierPullAdmitted(field, verb); + const Bool escalated = MGPipeApplierCurrentRecordIsBarrieredByEscalation(); + if (!statically && !escalated) { + StrictBarrierPullFatal(field, verb, "MOBILEGL_IPC_STRICT_ERRORS=1"); + } + AdmittedBarrierPullOnce(field, verb, !statically); + } + } + + // The verb's OWN may-read table (FillPoints.def, kMGPipeClassFieldMask). The stamp + // respects it for the same reason the client's residual fill does: a field outside the + // verb's class is one the fill never copied, so answering it out of gPipeInputs would + // hand the server the PREVIOUS verb's value - the exact staleness the generation poison + // exists to catch, re-introduced by the very mechanism meant to instrument it. + Bool FieldIsInVerbClass(MGPipeInputField field, MGPipeVerb verb) { + const SizeT verbIndex = static_cast(verb); + if (verbIndex >= kMGPipeVerbCount) return false; + const MGPipeVerbClass verbClass = kMGPipeVerbClass[verbIndex]; + return MGPipeFieldMaskHas(kMGPipeClassFieldMask[static_cast(verbClass)], field); + } + + // ---- the same verdict, per verb CLASS, as a constant (P5d round 3, package C) ---- + // + // THE STAMP's ANSWER IS A CONSTANT OF THE VERB CLASS AND OF NOTHING ELSE. Both halves of + // `answerable` below read constexpr tables only - kMGPipeFieldOwnership, kMGPipeVerbClass + // and kMGPipeClassFieldMask - so the 63-field loop was recomputing, at every verb on the + // apply thread, a table the compiler can build once. The 2026-09-17 inproc profile put + // MGPipeServerStampVerbBoundary at 1.1% self of the apply thread (Minecraft 26.3-rc-3, + // ~852 draws/frame), and every cycle taken there is a cycle the lockstep client waits for. + // + // IT IS A MASK, NOT A BOOL ARRAY, so the stamp loop's body stays a shift and a store with + // no branch: FilledGen[i] = serial & -bit, which is `serial` for an answerable field and + // the WITHDRAWAL 0 for every other. The verdict itself is unchanged, and it is still + // computed by the one expression argued at the stamp - it just runs at compile time. + constexpr MGPipeFieldMask AnswerableMaskForClass(MGPipeVerbClass verbClass) { + MGPipeFieldMask mask{}; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + const MGPipeFieldOwnership ownership = kMGPipeFieldOwnership[i]; + const Bool answerable = (ownership == MGPipeFieldOwnership::kRecordSupplied || + ownership == MGPipeFieldOwnership::kApplierDerived) && + MGPipeFieldMaskHas( + kMGPipeClassFieldMask[static_cast(verbClass)], field); + if (answerable) mask.Words[i / 64] |= (Uint64{1} << (i % 64)); + } + return mask; + } + + struct AnswerableMaskTable { + MGPipeFieldMask ByClass[kMGPipeVerbClassCount]; + }; + + constexpr AnswerableMaskTable MakeAnswerableMaskTable() { + AnswerableMaskTable table{}; + for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) { + table.ByClass[i] = AnswerableMaskForClass(static_cast(i)); + } + return table; + } + + constexpr AnswerableMaskTable kAnswerableByClass = MakeAnswerableMaskTable(); + + // A verb id outside the table answers NOTHING, which is exactly what FieldIsInVerbClass + // said for it (`verbIndex >= kMGPipeVerbCount` -> false for every field) and therefore + // what the stamp said: all 63 withdrawn, every read the poison Fatal. + constexpr MGPipeFieldMask kNoFieldIsAnswerable{}; + + const MGPipeFieldMask& AnswerableMaskForVerb(MGPipeVerb verb) { + const SizeT verbIndex = static_cast(verb); + if (verbIndex >= kMGPipeVerbCount) return kNoFieldIsAnswerable; + return kAnswerableByClass.ByClass[static_cast(kMGPipeVerbClass[verbIndex])]; + } + } // namespace + + // The third door into the storage (see PipeInputs.h). It exists because neither of the + // other two can be the one that stamps: MGPipeApplyAccess deliberately does not, and + // MGPipeFillAccess lives in MG_Impl, the role a server does not have. + struct MGPipeStampAccess { + static MGPipeFilledState& Filled(PipeInputs& inputs) { return inputs.m_filled; } + static void SetVerb(PipeInputs& inputs, MGPipeVerb verb) { inputs.m_currentVerb = verb; } + static void SetServerStamped(PipeInputs& inputs, Bool stamped) { + inputs.m_serverStampedVerb = stamped; + } + }; + + void MGPipeServerStampVerbBoundary(MGPipeVerb verb) { + PipeInputs& inputs = gPipeInputs; + // P5e (gl), ID-115: THE POSITIVE CONTROL'S ONLY HONEST SIGNAL, and it belongs HERE + // because this is the line the whole strict mechanism hangs off. The poison, rsp and + // the BARRIER-PULLED verdict are all reachable only after this stamp, and the monolith + // arm never stamps at all - so "the strict lane is green" and "strict was never armed" + // were observationally identical, which is how a lane whose seven passing entries + // included no record-carrying split GL scenario read as rigour. Behind Enabled(), which + // is the file's rule for counters (`rsp` beside it does the same) and keeps the stamp a + // table lookup and a branch-free fill when stats are off. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ServerVerbBoundaries, 1); + } + MGPipeFilledState& filled = MGPipeStampAccess::Filled(inputs); + MGPipeStampAccess::SetVerb(inputs, verb); + // Starts at 1 for MGPipeValidateForVerb's reason: FilledGen == 0 is "never filled" on + // BOTH branches of MGPipeInputFieldIsFresh, so the zeroing below is a real withdrawal + // rather than a stamp that happens to be old. + ++filled.CurrentVerbSerial; + // STAMPED: in this verb's class AND answerable out of the records the applier has + // already applied. WITHDRAWN (0): everything else - which is BARRIER-PULLED, FATAL, + // and anything the verb's own may-read table says this verb does not read. + // + // The withdrawal is the load-bearing half of the rule: the client's residual fill + // stamped all 63 fields at its own verb boundary, so without it every field would + // read fresh on the server, `rsp` would be identically 0 and the exit gate would be + // decoration. It also cancels the sticky exemption for free - generated/ + // PipeFilled.inc tests "never filled" BEFORE it tests sticky, so 0 wins over + // kMGPipeInputFieldSticky without a line of the generated file changing. + // + // THE VERDICT IS THE SAME EXPRESSION; it is just precomputed per verb class + // (AnswerableMaskForClass above) instead of re-derived 63 times per verb, so what is + // left here is a table lookup and a branch-free fill. + const MGPipeFieldMask& answerable = AnswerableMaskForVerb(verb); + const Uint64 serial = filled.CurrentVerbSerial; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const Uint64 bit = (answerable.Words[i / 64] >> (i % 64)) & Uint64{1}; + filled.FilledGen[i] = serial & (Uint64{0} - bit); + } + MGPipeStampAccess::SetServerStamped(inputs, true); + } + + void MGPipeServerClearVerbBoundary() { MGPipeStampAccess::SetServerStamped(gPipeInputs, false); } + + Uint64 MGPipeResidualPullCount() { return g_residualPulls; } + void MGPipeResetResidualPullCountForTesting() { g_residualPulls = 0; } + + void MGPipeInputUnfreshRead(MGPipeInputField field, MGPipeVerb verb, Bool serverStamped) { + // OUTSIDE A SERVER-STAMPED VERB THIS IS THE MONOLITH ANSWER, UNCHANGED. A split BUILD + // running MOBILEGL_TRANSPORT=monolith - every unit and integration-gpu lane of + // build-split - has a client that stamped all 63 fields, so a stale read there is the + // same defect it is in a verify build. Softening it on the build rather than on the + // stamp would take 1842 unit cases' ability to go red away with it. + // + // AND A READ OUTSIDE THE VERB'S OWN CLASS IS STILL FATAL even for a BARRIER-PULLED + // field: the value it would be answered with was never copied for this verb, so + // counting it would trade a loud staleness for a quiet one. + if (!serverStamped || + kMGPipeFieldOwnership[static_cast(field)] != MGPipeFieldOwnership::kBarrierPulled || + !FieldIsInVerbClass(field, verb)) { + // P5c (gt, CONTRACT-P5C §6 layer 1): under a server stamp, a stale read of a field + // whose row is RECORD-SUPPLIED or APPLIER-DERIVED - i.e. a value a pushed record + // DOES carry, read where this verb's stamp does not cover it - is a role violation + // named by its surface, not a generic unmigrated read: the wire already owns the + // answer, so reaching past it into the residual fill is rule E's shape. A + // BARRIER-PULLED field outside the verb's class and a FATAL field keep the poison + // Fatal - those are the stamp table's own verdicts, not a role's overreach. + const MGPipeFieldOwnership ownership = kMGPipeFieldOwnership[static_cast(field)]; + if (serverStamped && (ownership == MGPipeFieldOwnership::kRecordSupplied || + ownership == MGPipeFieldOwnership::kApplierDerived)) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"%s\"} - the server read this field stale " + "at %s, but a pushed record carries it (the row is %s): the read reached " + "the client's residual fill for a value the wire already owns", + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(verb), + MGPipeFieldOwnershipName(ownership)); + std::abort(); + } + MGPipeInputPoisonFatalForVerb(field, verb); + } + CountBarrierPull(field, verb); + } + + Bool MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped) { + if (!serverStamped) return false; + const MGPipeFieldOwnership narrowed = MGPipeFieldOwnershipOf(field, arg0); + if (narrowed == MGPipeFieldOwnershipOf(field)) return false; // the argument narrows nothing + if (narrowed == MGPipeFieldOwnership::kFatal) { + // The field's own stamp says fresh - the applier really did write the half that has + // a carrier - so only the argument can say that THIS read is unserved. THE MESSAGE + // NAMES THE ARGUMENT, because without it this line is byte-identical to what a + // genuinely stale read of the OTHER half would print, and the whole case for + // narrowing by argument rather than by a second field id is that the reader is told + // which half they asked for. + MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, \"%s@%s\"} [argument 0 = %u is %s while the " + "field is %s]", + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(verb), arg0, + MGPipeFieldOwnershipName(narrowed), + MGPipeFieldOwnershipName(MGPipeFieldOwnershipOf(field))); + std::abort(); + } + if (narrowed == MGPipeFieldOwnership::kBarrierPulled) { + CountBarrierPull(field, verb); + return true; // decided here; the field-level check must not count it again + } + return true; + } + + void MGPipeStickyForwardPull(MGPipeInputField field) { + // The seven carry no MGP_INPUT_CHECK at all (the declared exception argued at + // PipeInputs.h's F-class block), so freshness can never reach them and neither can the + // stamp's withdrawal. This is the only thing that puts them in `rsp`. + if (!gPipeInputs.ServerStampedVerb()) return; + CountBarrierPull(field, gPipeInputs.CurrentVerb()); + } +#endif // MOBILEGL_BUILD_DISAGGREGATED + +#if MOBILEGL_PIPE_VERIFY + namespace { + using CurrentVertexAttributeValue = PipeInputs::CurrentVertexAttributeValue; + + // Every overload is declared up front: the array overloads recurse into their element + // type, and a call inside a template only sees what was declared before the template. + template + Bool StorageEqual(const T& a, const T& b); + template + Bool StorageEqual(T* const& a, T* const& b); + template + Bool StorageEqual(const SharedPtr& a, const SharedPtr& b); + template + Bool StorageEqual(const T (&a)[N], const T (&b)[N]); + Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b); + Bool StorageEqual(const CurrentVertexAttributeValue& a, const CurrentVertexAttributeValue& b); + template + void CorruptStorage(T& v); + template + void CorruptStorage(T*& p); + template + void CorruptStorage(SharedPtr& p); + template + void CorruptStorage(T (&a)[N]); + void CorruptStorage(PipeInputs::IndexedCapabilities& c); + void CorruptStorage(CurrentVertexAttributeValue& v); + + // ---- equality over one field's storage ---- + // O-class storage compares by identity: a raw pointer into the context, or the object a + // SharedPtr owns. Everything else goes through G4's MGPipeFieldEqual, recursing through + // C arrays element-wise. + template + Bool StorageEqual(T* const& a, T* const& b) { + return a == b; + } + template + Bool StorageEqual(const SharedPtr& a, const SharedPtr& b) { + return a.get() == b.get(); + } + template + Bool StorageEqual(const T (&a)[N], const T (&b)[N]) { + for (SizeT i = 0; i < N; ++i) { + if (!StorageEqual(a[i], b[i])) return false; + } + return true; + } + Bool StorageEqual(const PipeInputs::IndexedCapabilities& a, const PipeInputs::IndexedCapabilities& b) { + return StorageEqual(a.Blend, b.Blend) && StorageEqual(a.ScissorTest, b.ScissorTest); + } + // Three scalar arrays and nothing else (Core.h), so a bitwise compare has no padding to + // false-differ on and keeps a NaN float attribute equal to itself. The size assertion is + // what turns a fourth member into a build break rather than a blind spot. + Bool StorageEqual(const CurrentVertexAttributeValue& a, const CurrentVertexAttributeValue& b) { + static_assert(sizeof(CurrentVertexAttributeValue) == 3 * 4 * 4, + "CurrentVertexAttributeValue grew a member; update the comparator"); + return std::memcmp(&a, &b, sizeof(CurrentVertexAttributeValue)) == 0; + } + template + Bool StorageEqual(const T& a, const T& b) { + return MGPipeFieldEqual(a, b); + } + + // ---- corruption of one field's storage ---- + // Every shape is perturbed in a way the comparator above must see: a Bool flips, a + // scalar or enum moves by one, a pointer's low bits are flipped (never dereferenced: + // the snapshot is only ever compared), a SharedPtr becomes an aliasing pointer to a + // flipped address with no control block, an array corrupts its first element, and any + // other struct has its first byte XOR'ed with 0x5A. + template + T* FlipPointer(T* p) { + return reinterpret_cast(reinterpret_cast(p) ^ 0x5A); + } + template + void CorruptStorage(T*& p) { + p = FlipPointer(p); + } + template + void CorruptStorage(SharedPtr& p) { + p = SharedPtr(SharedPtr(), FlipPointer(p.get())); + } + template + void CorruptStorage(T (&a)[N]) { + CorruptStorage(a[0]); + } + void CorruptStorage(PipeInputs::IndexedCapabilities& c) { + CorruptStorage(c.Blend); + } + void CorruptStorage(CurrentVertexAttributeValue& v) { + v.floatValue[0] += 1.f; + } + template + void CorruptStorage(T& v) { + if constexpr (std::is_same_v) { + v = !v; + } else if constexpr (std::is_enum_v) { + v = static_cast(static_cast>(v) + 1); + } else if constexpr (std::is_arithmetic_v) { + v = static_cast(v + 1); + } else { + static_assert(std::is_trivially_copyable_v, "PipeInputs storage must be trivially copyable"); + unsigned char first = 0; + std::memcpy(&first, &v, 1); + first ^= 0x5A; + std::memcpy(&v, &first, 1); + } + } + } // namespace + + Bool MGPipeInputsFieldEqual(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b) { + // A forwarded field has no storage and is equal by definition; VisitStorage answers + // false for it, hence the explicit sticky test first. + if (kMGPipeInputFieldSticky[static_cast(field)]) return true; + return PipeInputs::VisitStorage(field, a, b, [](const auto& x, const auto& y) { return StorageEqual(x, y); }); + } + + Bool MGPipeVerifyInputs(const PipeInputs& pushed, const PipeInputs& snapshot, const MGPipeFieldMask& mask, + MGPipeInputField* outField) { + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field)) continue; + if (MGPipeInputsFieldEqual(field, pushed, snapshot)) continue; + if (outField != nullptr) *outField = field; + return false; + } + return true; + } + + Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field) { + return PipeInputs::VisitStorage(field, snapshot, snapshot, [](auto& x, auto&) { + CorruptStorage(x); + return true; + }); + } +#endif // MOBILEGL_PIPE_VERIFY +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Backend/MGPipe/PipeInputs.h b/MobileGL/MG_Backend/MGPipe/PipeInputs.h new file mode 100644 index 000000000..26d3daf9b --- /dev/null +++ b/MobileGL/MG_Backend/MGPipe/PipeInputs.h @@ -0,0 +1,942 @@ +// MobileGL - MobileGL/MG_Backend/MGPipe/PipeInputs.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 +// The frontend types the accessors return. Allowed here: P13 keeps this include for the +// verify arm (ARCHITECTURE.md 9.5). This header spells no MG_State global - every read of +// the live context happens on the client side, in MG_Impl/Pipe/PipeFill.cpp. +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c (rv, CONTRACT-P5C.md §5.3): the three texture shutters' server-side answer lives in the +// applier - MGPipeApplierTextureShutterSerial() / MGPipeApplierContextSerial(), declared here +// so the accessors below can answer with them under a server-stamped verb. MG_Pipe is below +// MG_Backend, so this direction is the layering's, and PipeApply.h forward-declares +// PipeInputs rather than including this header, so there is no cycle. +#include +#endif + +// MOBILEGL_PIPE_POISON: the per-verb generation stamps and the read-side +// Fatal{UnmigratedPipeInput} check. Derived here, once. The repository's debug gate is +// MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG (Defines.h); the verify CI build is +// Release/INFO with MOBILEGL_BUILD_DISAGGREGATED=OFF, so the third arm is what arms the poison +// there without dragging MG_Remote in. +#if MOBILEGL_PIPE_PUSH && (MOBILEGL_LOG_ACTIVE_LEVEL <= MOBILEGL_LOG_LEVEL_DEBUG || MOBILEGL_BUILD_DISAGGREGATED || \ + MOBILEGL_PIPE_VERIFY) +#define MOBILEGL_PIPE_POISON 1 +#else +#define MOBILEGL_PIPE_POISON 0 +#endif + +namespace MobileGL::MG_Pipe { +// TABLE 2 (CONTRACT-P5.md section 3, R-7): the four ownership classes, one per field, plus +// the seven sticky forwards' own rows. Included HERE rather than from MG_Pipe/MGPipe.h with +// gen_pipe.py's seven outputs, deliberately: MGPipe.h is in the PULL build's include closure +// and G1 admits no symbol motion there, while this header is reached only through +// PipeInputsSwitch.h's MOBILEGL_PIPE_PUSH arm. It is also exactly the header the poison check +// below and the server's verb stamp both already see. +#include + + // PipeInputs.cpp. The poison Fatal with the verb's name ("" before the first + // verb): MGLOG_F + std::abort(), live at every log level on purpose - this is not + // MOBILEGL_ASSERT, which is inert in INFO builds. + [[noreturn]] void MGPipeInputPoisonFatalForVerb(MGPipeInputField field, MGPipeVerb verb); + // kMGPipeVerbNames[verb], or "" for kVerbCount (no verb has been filled yet). + const char* MGPipeVerbName(MGPipeVerb verb); + // Name lookups for the runtime knobs (MOBILEGL_PIPE_VERIFY_CORRUPT names a field, + // MOBILEGL_PIPE_POISON_OMIT a Verb:Field pair). Empty on an unknown name. + Optional MGPipeFindInputField(const char* name); + Optional MGPipeFindVerb(const char* name); + +#if MOBILEGL_BUILD_DISAGGREGATED + // ---- P5: the split arm of the read check (R-7.2, R-7.3) ------------------------------ + // + // A stale read stops being one answer and becomes FOUR, keyed on the field's table-2 class + // - which is what turns the generated table from a document into a runtime mechanism: + // + // RECORD-SUPPLIED / APPLIER-DERIVED the server could answer it and did not: a real + // defect. Fatal, exactly as today. + // BARRIER-PULLED the server is reading the value the client's + // residual fill left in gPipeInputs while the verb + // barrier holds both threads apart (R-1). LEGAL, and + // COUNTED: PipeStats::CallClass::ResidualPulls. Under + // MOBILEGL_IPC_STRICT_ERRORS=1 it is Fatal instead. + // FATAL no carrier and the reduced path never reads it. + // + // AND IT IS ARMED ONLY INSIDE A SERVER-STAMPED VERB (PipeInputs::ServerStampedVerb). + // A split BUILD running monolith transport - which is every unit and integration-gpu lane + // of build-split - has a client that fills and stamps all 63 fields at every verb, so a + // stale read there is the same defect it is in a verify build and gets the same Fatal. + // Without that condition the leniency would apply to lanes whose stamps are the client's, + // and 1842 unit cases would quietly stop being able to go red. + void MGPipeInputUnfreshRead(MGPipeInputField field, MGPipeVerb verb, Bool serverStamped); + // The same decision for an accessor that takes an argument the table narrows on + // (kMGPipeFieldArgumentOwnership). Called BEFORE the freshness test, because the narrowed + // class is a statement about the argument rather than about the stamp: the pack half of + // GetPixelStoreParameters is stamped and fresh while the unpack half has no carrier and no + // backend reader at all. + // + // RETURNS TRUE WHEN THE ARGUMENT ROW DECIDED, and the caller then skips the field-level + // check. Today the only row narrows to FATAL, which aborts, so the return value changes + // nothing; the day a row narrows to BARRIER-PULLED it is what stops the field-level check + // from either counting the same read twice or - worse, because the field's own class would + // not be BARRIER-PULLED - aborting a read the argument row had just declared legal. + Bool MGPipeInputArgumentRead(MGPipeInputField field, Uint32 arg0, MGPipeVerb verb, Bool serverStamped); +#endif + + // The read-side poison check, on every non-forwarded accessor. Under MOBILEGL_PIPE_POISON + // a read of a field whose stamp is older than the current verb serial is + // Fatal{UnmigratedPipeInput, "Field@Verb"}; otherwise the accessor is a plain load. +#if MOBILEGL_PIPE_POISON +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGP_INPUT_CHECK(Field) \ + do { \ + if (!::MobileGL::MG_Pipe::MGPipeInputFieldIsFresh(m_filled, (Field))) { \ + ::MobileGL::MG_Pipe::MGPipeInputUnfreshRead((Field), m_currentVerb, m_serverStampedVerb); \ + } \ + } while (0) +#define MGP_INPUT_CHECK_ARG(Field, Arg0) \ + do { \ + if (!::MobileGL::MG_Pipe::MGPipeInputArgumentRead((Field), static_cast(Arg0), m_currentVerb, \ + m_serverStampedVerb)) { \ + MGP_INPUT_CHECK(Field); \ + } \ + } while (0) +#else +#define MGP_INPUT_CHECK(Field) \ + do { \ + if (!::MobileGL::MG_Pipe::MGPipeInputFieldIsFresh(m_filled, (Field))) { \ + ::MobileGL::MG_Pipe::MGPipeInputPoisonFatalForVerb((Field), m_currentVerb); \ + } \ + } while (0) +#define MGP_INPUT_CHECK_ARG(Field, Arg0) MGP_INPUT_CHECK(Field) +#endif +#else +#define MGP_INPUT_CHECK(Field) ((void)0) +#define MGP_INPUT_CHECK_ARG(Field, Arg0) ((void)0) +#endif + // The compare-at-read hook of the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8), defined + // in MG_Impl/Pipe/PipeFill.cpp: re-reads the field from the live context and compares it + // against the stored value, and reports the FIRST divergence as + // Fatal{PipeVerifyDiffer, "Field@Verb", verb=, where=read} (the indices go in a + // preceding MGLOG_E). Only the live block (gPipeInputs) is verified; a snapshot's own + // accessors are plain loads. Off in every other build. + struct PipeInputs; +#if MOBILEGL_PIPE_VERIFY + void MGPipeVerifyReadHook(const PipeInputs& self, MGPipeInputField field, Uint index0, Uint index1); +#define MGP_INPUT_VERIFY_READ(Field, Index0, Index1) \ + ::MobileGL::MG_Pipe::MGPipeVerifyReadHook(*this, (Field), static_cast(Index0), static_cast(Index1)) +#else +#define MGP_INPUT_VERIFY_READ(Field, Index0, Index1) ((void)0) +#endif + + // The V/O storage of every field that has storage, by field id. The seven F-class + // (forwarded) fields have none. PipeInputs::VisitStorage dispatches on this list, which + // is what keeps the comparator and the corruption injector one function each instead of + // two sixty-way switches. + // clang-format off +#define MGP_INPUT_STORAGE_LIST(X) \ + X(GetActiveTextureUnit, m_activeTextureUnit) \ + X(GetBlendColor, m_blendColor) \ + X(GetBlendEquationIndexed, m_blendEquation) \ + X(GetBlendFuncIndexed, m_blendFunc) \ + X(GetBoundTransformFeedbackName, m_boundTransformFeedbackName) \ + X(GetBoundVertexArray, m_boundVertexArray) \ + X(GetBufferBindingSlot, m_bufferBindingSlot) \ + X(GetBufferBindingPoint, m_bufferBindingPointBase) \ + X(GetTouchedBufferBindingPointCount, m_touchedBindingPointCount) \ + X(GetClampReadColor, m_clampReadColor) \ + X(GetClearColor, m_clearColor) \ + X(GetClearDepth, m_clearDepth) \ + X(GetClearStencil, m_clearStencil) \ + X(GetColorMaskIndexed, m_colorMask) \ + X(GetCullFaceMode, m_cullFaceMode) \ + X(GetCurrentVertexAttribute, m_currentVertexAttribute) \ + X(GetDepthFunc, m_depthFunc) \ + X(GetDepthMask, m_depthMask) \ + X(GetDepthRangeIndexed, m_depthRange) \ + X(GetFramebufferBindingSlot, m_framebufferBindingSlot) \ + X(GetImageTextureBinding, m_imageTextureBindingBase) \ + X(GetLineWidth, m_lineWidth) \ + X(GetLogicOp, m_logicOp) \ + X(GetMaxTouchedTextureUnit, m_maxTouchedTextureUnit) \ + X(GetMinSampleShadingValue, m_minSampleShadingValue) \ + X(GetPatchDefaultInnerLevel, m_patchDefaultInnerLevel) \ + X(GetPatchDefaultOuterLevel, m_patchDefaultOuterLevel) \ + X(GetPatchVertices, m_patchVertices) \ + X(GetPipelineStateVersion, m_pipelineStateVersion) \ + X(GetPixelStoreParameters, m_pixelStore) \ + X(GetPolygonModeFront, m_polygonModeFront) \ + X(GetPolygonOffsetFactor, m_polygonOffsetFactor) \ + X(GetPolygonOffsetUnits, m_polygonOffsetUnits) \ + X(GetPrimitiveRestartIndex, m_primitiveRestartIndex) \ + X(GetProgramForDispatch, m_programForDispatch) \ + X(GetProgramForDraw, m_programForDraw) \ + X(GetProvokingVertexMode, m_provokingVertexMode) \ + X(GetRenderStateParameters, m_renderState) \ + X(GetRenderStateParametersVersion, m_renderStateParametersVersion) \ + X(GetSamplingResolutionGeneration, m_samplingResolutionGeneration) \ + X(GetScissorBox, m_scissorBox) \ + X(GetStencilState, m_stencil) \ + X(GetTextureBindGeneration, m_textureBindGeneration) \ + X(GetTextureContextId, m_textureContextId) \ + X(GetTextureUnitObject, m_textureUnitBase) \ + X(GetTransformFeedbackCapturedVertices, m_transformFeedbackCapturedVertices) \ + X(GetTransformFeedbackGeneration, m_transformFeedbackGeneration) \ + X(GetTransformFeedbackPausedPrimitiveCounter, m_transformFeedbackPausedPrimitiveCounter) \ + X(GetTransformFeedbackProgram, m_transformFeedbackProgram) \ + X(GetViewport, m_viewport) \ + X(GetViewportIndexed, m_viewportIndexed) \ + X(IsCapabilityEnabled, m_capability) \ + X(IsCapabilityEnabledIndexed, m_capabilityIndexed) \ + X(IsTransformFeedbackActive, m_transformFeedbackActive) \ + X(IsTransformFeedbackPaused, m_transformFeedbackPaused) \ + X(GetBoundTransformFeedbackLifetimeId, m_boundTransformFeedbackLifetimeId) + // clang-format on + + // The seven F-class fields, for the arithmetic below and for the sticky table's proof. + // The forwarded set IS the sticky set (PipeFields.def marks the same seven rows F and + // sticky), so an eighth sticky row without a forwarder is refused here, not by a test. + inline constexpr SizeT kMGPipeForwardedFieldCount = 7; + static_assert(kMGPipeForwardedFieldCount == kMGPipeInputStickyFieldCount, + "the forwarded (F-class) fields and the sticky fields of PipeFields.def are the same seven rows"); + + // The block the backends read instead of GLContext (ARCHITECTURE.md 9.2 phase A, P1 brief + // D4). One struct, three storage classes, and every accessor keeps the NAME, PARAMETERS + // and RETURN TYPE of its GLContext counterpart (MG_State/GLState/Core.h) so the strangler + // sed is type-neutral: + // + // V (value) copied out of GLContext at fill time by calling the same accessor; + // no derivation logic is re-implemented here, which is what keeps the + // copy semantically identical by construction. + // O (object reference) a SharedPtr copy, or a raw pointer to the live GLContext-owned + // slot/array for the accessors that return a non-const reference into + // the context. Identity is what phase C turns into a handle. + // F (forwarded) argument-keyed lookups and reverse-channel calls, defined out of + // line in MG_Impl/Pipe/PipeFill.cpp (the client side, where the live + // context may be spelled). Sticky: stamped once by the first fill that + // sees a live context. + // + // Every non-forwarded accessor is MGP_INPUT_CHECK (poison) -> MGP_INPUT_VERIFY_READ + // (compare-at-read) -> the storage. Both macros expand to nothing when their switch is + // off, so a plain MOBILEGL_PIPE_PUSH build's accessor is a load. + struct PipeInputs { + using GLContext = MG_State::GLState::GLContext; + using BufferObject = MG_State::GLState::BufferObject; + using BufferTarget = ::MobileGL::BufferTarget; + using FramebufferObject = MG_State::GLState::FramebufferObject; + using FramebufferTarget = ::MobileGL::FramebufferTarget; + using VertexArrayObject = MG_State::GLState::VertexArrayObject; + using ProgramObject = MG_State::GLState::ProgramObject; + using ITextureObject = MG_State::GLState::ITextureObject; + using TextureUnit = MG_State::GLState::TextureUnit; + using ImageTextureBinding = MG_State::GLState::ImageTextureBinding; + using CurrentVertexAttributeValue = MG_State::GLState::CurrentVertexAttributeValue; + + static constexpr SizeT kBufferTargetCount = static_cast(BufferTarget::BufferTargetCount); + static constexpr SizeT kFramebufferTargetCount = static_cast(FramebufferTarget::FramebufferTargetCount); + static constexpr SizeT kCapabilityCount = static_cast(CapabilityInput::CapabilityInputCount); + static constexpr SizeT kMaxViewports = RenderStateParameters::MAX_VIEWPORTS; + static constexpr SizeT kMaxVertexAttribs = VertexArrayObject::MAX_VERTEX_ATTRIBS; + static constexpr SizeT kStencilFaceCount = static_cast(StencilFace::StencilFaceCount); + + // IsCapabilityEnabledIndexed's two indexed capabilities, the only ones GLContext keeps + // indexed state for (RenderState::IsCapabilityEnabledIndexed). + struct IndexedCapabilities { + Bool Blend[kMGMaxDrawBuffers]; + Bool ScissorTest[kMaxViewports]; + }; + + // ---- identity / liveness (not fields) ---- + // Whether a live GLContext exists. Forwarded (PipeFill.cpp): under push MGB_CTX_LIVE + // must be true as soon as a context exists, fill or no fill, which is what today's + // null-context guards test. + Bool IsLive() const; + // The live GLContext's address at the last fill; serves MGB_CTX_IDENTITY. + const void* ContextIdentity() const { return m_contextIdentity; } + // The verb of the last fill, kVerbCount before the first one. + MGPipeVerb CurrentVerb() const { return m_currentVerb; } +#if MOBILEGL_PIPE_POISON + const MGPipeFilledState& FilledState() const { return m_filled; } +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // TRUE between the server's verb-boundary stamp and whoever clears it. It is the + // arming condition of the whole split read path: only inside a server-stamped verb is + // a BARRIER-PULLED read counted rather than Fatal, and only there is a sticky forward + // a residual pull rather than an ordinary monolith call. A split build running + // monolith transport never sets it, which is why build-split's unit and integration + // cases behave exactly as a verify build's do. + // + // CLEARING IT IS THE APPLIER'S JOB AND NOT THE CLIENT'S, even though the client also + // does it. MGPipeValidateForVerb and MGPipeLeaveVerb both call + // MGPipeServerClearVerbBoundary, which is sufficient for inproc, where both roles share + // one process and one gPipeInputs - and misleading for P6, where MG_Impl is not in the + // server at all. There this flag would latch TRUE for the life of the server after the + // first stamp, every later read anywhere would be judged against the last verb's mask, + // and MGPipeStickyForwardPull would stop being a no-op outside a verb - so + // InvalidateCompileEnv reached from a later context's backend initialisation, the exact + // case the sticky exemption was written for, would be counted and, under strict, would + // abort. So: PipeApplier clears on leaving the applier. Not optional. + Bool ServerStampedVerb() const { return m_serverStampedVerb; } +#endif + + // ---- V: values ---- + Int GetActiveTextureUnit() const { + MGP_INPUT_CHECK(MGPipeInputField::GetActiveTextureUnit); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetActiveTextureUnit, 0, 0); + return m_activeTextureUnit; + } + const FloatVec4& GetBlendColor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetBlendColor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBlendColor, 0, 0); + return m_blendColor; + } + void GetBlendEquationIndexed(Uint index, BlendEquation& color, BlendEquation& alpha) const { + MGP_INPUT_CHECK(MGPipeInputField::GetBlendEquationIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBlendEquationIndexed, index, 0); + if (index >= kMGMaxDrawBuffers) { + MOBILEGL_ASSERT(false, "Blend equation index out of range: %u", index); + return; + } + color = m_blendEquation[index][0]; + alpha = m_blendEquation[index][1]; + } + void GetBlendFuncIndexed(Uint index, BlendFactor& srcRGB, BlendFactor& dstRGB, BlendFactor& srcAlpha, + BlendFactor& dstAlpha) const { + MGP_INPUT_CHECK(MGPipeInputField::GetBlendFuncIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBlendFuncIndexed, index, 0); + if (index >= kMGMaxDrawBuffers) { + MOBILEGL_ASSERT(false, "Blend func index out of range: %u", index); + return; + } + srcRGB = m_blendFunc[index][0]; + dstRGB = m_blendFunc[index][1]; + srcAlpha = m_blendFunc[index][2]; + dstAlpha = m_blendFunc[index][3]; + } + // Dead field: filled, read by no backend since the D21 XFB counter-slot rekey; kept so + // the vendored inventory row keeps its mapping (Coverage.def). + Uint GetBoundTransformFeedbackName() const { + MGP_INPUT_CHECK(MGPipeInputField::GetBoundTransformFeedbackName); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBoundTransformFeedbackName, 0, 0); + return m_boundTransformFeedbackName; + } + SizeT GetTouchedBufferBindingPointCount(BufferTarget target) const { + MGP_INPUT_CHECK(MGPipeInputField::GetTouchedBufferBindingPointCount); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTouchedBufferBindingPointCount, static_cast(target), 0); + return m_touchedBindingPointCount[static_cast(target)]; + } + GLenum GetClampReadColor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClampReadColor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClampReadColor, 0, 0); + return m_clampReadColor; + } + const FloatVec4& GetClearColor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClearColor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClearColor, 0, 0); + return m_clearColor; + } + Float GetClearDepth() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClearDepth); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClearDepth, 0, 0); + return m_clearDepth; + } + Uint32 GetClearStencil() const { + MGP_INPUT_CHECK(MGPipeInputField::GetClearStencil); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetClearStencil, 0, 0); + return m_clearStencil; + } + BoolVec4 GetColorMaskIndexed(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetColorMaskIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetColorMaskIndexed, index, 0); + return m_colorMask[index]; + } + CullFaceMode GetCullFaceMode() const { + MGP_INPUT_CHECK(MGPipeInputField::GetCullFaceMode); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetCullFaceMode, 0, 0); + return m_cullFaceMode; + } + const CurrentVertexAttributeValue& GetCurrentVertexAttribute(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetCurrentVertexAttribute); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetCurrentVertexAttribute, index, 0); + if (index >= kMaxVertexAttribs) { + static const CurrentVertexAttributeValue defaultValue{}; + MGLOG_E_ONCE("PipeInputs::GetCurrentVertexAttribute: index %u is out of range", index); + return defaultValue; + } + return m_currentVertexAttribute[index]; + } + DepthTestFunc GetDepthFunc() const { + MGP_INPUT_CHECK(MGPipeInputField::GetDepthFunc); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetDepthFunc, 0, 0); + return m_depthFunc; + } + Bool GetDepthMask() const { + MGP_INPUT_CHECK(MGPipeInputField::GetDepthMask); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetDepthMask, 0, 0); + return m_depthMask; + } + const FloatVec2& GetDepthRangeIndexed(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetDepthRangeIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetDepthRangeIndexed, index, 0); + if (index >= kMaxViewports) { + MOBILEGL_ASSERT(false, "Depth range index out of range: %u", index); + return m_depthRange[0]; + } + return m_depthRange[index]; + } + Float GetLineWidth() const { + MGP_INPUT_CHECK(MGPipeInputField::GetLineWidth); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetLineWidth, 0, 0); + return m_lineWidth; + } + LogicOperation GetLogicOp() const { + MGP_INPUT_CHECK(MGPipeInputField::GetLogicOp); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetLogicOp, 0, 0); + return m_logicOp; + } + Int GetMaxTouchedTextureUnit() const { + MGP_INPUT_CHECK(MGPipeInputField::GetMaxTouchedTextureUnit); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetMaxTouchedTextureUnit, 0, 0); + return m_maxTouchedTextureUnit; + } + Float GetMinSampleShadingValue() const { + MGP_INPUT_CHECK(MGPipeInputField::GetMinSampleShadingValue); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetMinSampleShadingValue, 0, 0); + return m_minSampleShadingValue; + } + const FloatVec2& GetPatchDefaultInnerLevel() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPatchDefaultInnerLevel); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPatchDefaultInnerLevel, 0, 0); + return m_patchDefaultInnerLevel; + } + const FloatVec4& GetPatchDefaultOuterLevel() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPatchDefaultOuterLevel); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPatchDefaultOuterLevel, 0, 0); + return m_patchDefaultOuterLevel; + } + Uint GetPatchVertices() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPatchVertices); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPatchVertices, 0, 0); + return m_patchVertices; + } + Uint GetPipelineStateVersion() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPipelineStateVersion); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPipelineStateVersion, 0, 0); + return m_pipelineStateVersion; + } + Uint GetRenderStateParametersVersion() const { + MGP_INPUT_CHECK(MGPipeInputField::GetRenderStateParametersVersion); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetRenderStateParametersVersion, 0, 0); + return m_renderStateParametersVersion; + } + // THE ONE FIELD TABLE 2 NARROWS BY ARGUMENT. m_pixelStore[2] is one array indexed by + // this accessor's own argument, exactly as m_bufferBindingSlot[15] is indexed by a + // BufferTarget, and Coverage.def:62-69 already rules that such a field stays ONE row. + // Only [0] (pack) has a carrier - set_pixel_pack_state, which the applier writes + // (PipeApply.cpp:1373) - so the field is APPLIER-DERIVED and the UNPACK half is FATAL: + // every MGB_CTX->GetPixelStoreParameters site in the tree passes false + // (DirectGLES.cpp:7924, :9399, :10893, :11272, Utils.cpp:2302, + // VulkanRenderer.cpp:10980), and PipeFill.cpp's EmitPixelPackState says the same from + // the other side: "nothing on the far side of the boundary reads unpack state". + PixelStoreParameters GetPixelStoreParameters(Bool isUnpack) const { + MGP_INPUT_CHECK_ARG(MGPipeInputField::GetPixelStoreParameters, isUnpack ? 1u : 0u); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPixelStoreParameters, isUnpack ? 1u : 0u, 0); + return m_pixelStore[isUnpack ? 1 : 0]; + } + GLenum GetPolygonModeFront() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPolygonModeFront); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPolygonModeFront, 0, 0); + return m_polygonModeFront; + } + Float GetPolygonOffsetFactor() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPolygonOffsetFactor); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPolygonOffsetFactor, 0, 0); + return m_polygonOffsetFactor; + } + Float GetPolygonOffsetUnits() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPolygonOffsetUnits); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPolygonOffsetUnits, 0, 0); + return m_polygonOffsetUnits; + } + Uint32 GetPrimitiveRestartIndex() const { + MGP_INPUT_CHECK(MGPipeInputField::GetPrimitiveRestartIndex); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetPrimitiveRestartIndex, 0, 0); + return m_primitiveRestartIndex; + } + ProvokingVertexMode GetProvokingVertexMode() const { + MGP_INPUT_CHECK(MGPipeInputField::GetProvokingVertexMode); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetProvokingVertexMode, 0, 0); + return m_provokingVertexMode; + } + const RenderStateParameters& GetRenderStateParameters() const { + MGP_INPUT_CHECK(MGPipeInputField::GetRenderStateParameters); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetRenderStateParameters, 0, 0); + return m_renderState; + } + // THE THREE TEXTURE SHUTTERS (P5c rv, CONTRACT-P5C.md §5.3). Their FieldOwnership rows + // have said "a shutter, not a value: the server answers from its own Serial" since P5; + // rv is the edit that makes the accessor DO it. Under a SERVER-STAMPED verb the answer + // is the applier's own serial (APPLIER_DERIVED): server-owned, monotone, moved by every + // applied record that can move what the frontend generation guarded. Everywhere else - + // monolith, a split build on monolith transport, any read outside a stamped verb - the + // storage answer is kept, byte for byte (G1). + Uint64 GetSamplingResolutionGeneration() const { + MGP_INPUT_CHECK(MGPipeInputField::GetSamplingResolutionGeneration); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetSamplingResolutionGeneration, 0, 0); +#if MOBILEGL_BUILD_DISAGGREGATED + if (m_serverStampedVerb) return MGPipeApplierTextureShutterSerial(); +#endif + return m_samplingResolutionGeneration; + } + const IntVec4& GetScissorBox() const { + MGP_INPUT_CHECK(MGPipeInputField::GetScissorBox); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetScissorBox, 0, 0); + return m_scissorBox; + } + const StencilFaceState& GetStencilState(StencilFace face) const { + MGP_INPUT_CHECK(MGPipeInputField::GetStencilState); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetStencilState, static_cast(face), 0); + return m_stencil[face == StencilFace::Back ? 1 : 0]; + } + Uint64 GetTextureBindGeneration() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTextureBindGeneration); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTextureBindGeneration, 0, 0); +#if MOBILEGL_BUILD_DISAGGREGATED + // See GetSamplingResolutionGeneration: the server answers from its own Serial. + if (m_serverStampedVerb) return MGPipeApplierTextureShutterSerial(); +#endif + return m_textureBindGeneration; + } + Uint64 GetTextureContextId() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTextureContextId); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTextureContextId, 0, 0); +#if MOBILEGL_BUILD_DISAGGREGATED + // A context IDENTITY rather than a generation: stable within the served context, + // moved by every MGPipeApplierReset - which is all the backends' per-context memo + // keys ask of it. + if (m_serverStampedVerb) return MGPipeApplierContextSerial(); +#endif + return m_textureContextId; + } + Uint64 GetTransformFeedbackCapturedVertices() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackCapturedVertices); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackCapturedVertices, 0, 0); + return m_transformFeedbackCapturedVertices; + } + Uint64 GetTransformFeedbackGeneration() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackGeneration); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackGeneration, 0, 0); + return m_transformFeedbackGeneration; + } + Uint64 GetTransformFeedbackPausedPrimitiveCounter() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackPausedPrimitiveCounter); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackPausedPrimitiveCounter, 0, 0); + return m_transformFeedbackPausedPrimitiveCounter; + } + Uint64 GetBoundTransformFeedbackLifetimeId() const { + MGP_INPUT_CHECK(MGPipeInputField::GetBoundTransformFeedbackLifetimeId); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBoundTransformFeedbackLifetimeId, 0, 0); + return m_boundTransformFeedbackLifetimeId; + } + IntVec4 GetViewport() const { + MGP_INPUT_CHECK(MGPipeInputField::GetViewport); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetViewport, 0, 0); + return m_viewport; + } + const FloatVec4& GetViewportIndexed(Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::GetViewportIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetViewportIndexed, index, 0); + if (index >= kMaxViewports) { + MOBILEGL_ASSERT(false, "Viewport index out of range: %u", index); + return m_viewportIndexed[0]; + } + return m_viewportIndexed[index]; + } + Bool IsCapabilityEnabled(CapabilityInput cap) const { + MGP_INPUT_CHECK(MGPipeInputField::IsCapabilityEnabled); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsCapabilityEnabled, static_cast(cap), 0); + const auto index = static_cast(cap); + return index < kCapabilityCount ? m_capability[index] : false; + } + // Blend and ScissorTest are the only indexed capabilities GLContext keeps; no backend + // asks for another (VulkanRenderer asks Blend). Any other cap is a read the fill cannot + // have served: Fatal{UnmigratedPipeInput} naming the field and the verb, the cap in a + // preceding MGLOG_E. + Bool IsCapabilityEnabledIndexed(CapabilityInput cap, Uint index) const { + MGP_INPUT_CHECK(MGPipeInputField::IsCapabilityEnabledIndexed); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsCapabilityEnabledIndexed, static_cast(cap), index); + if (cap == CapabilityInput::Blend) { + return index < kMGMaxDrawBuffers ? m_capabilityIndexed.Blend[index] : false; + } + if (cap == CapabilityInput::ScissorTest) { + return index < kMaxViewports ? m_capabilityIndexed.ScissorTest[index] : false; + } + MGLOG_E("PipeInputs::IsCapabilityEnabledIndexed: no indexed storage for cap=%d (index=%u)", + static_cast(cap), index); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::IsCapabilityEnabledIndexed, m_currentVerb); + } + Bool IsTransformFeedbackActive() const { + MGP_INPUT_CHECK(MGPipeInputField::IsTransformFeedbackActive); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsTransformFeedbackActive, 0, 0); + return m_transformFeedbackActive; + } + Bool IsTransformFeedbackPaused() const { + MGP_INPUT_CHECK(MGPipeInputField::IsTransformFeedbackPaused); + MGP_INPUT_VERIFY_READ(MGPipeInputField::IsTransformFeedbackPaused, 0, 0); + return m_transformFeedbackPaused; + } + + // ---- O: object references ---- + const SharedPtr& GetBoundVertexArray() { + MGP_INPUT_CHECK(MGPipeInputField::GetBoundVertexArray); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBoundVertexArray, 0, 0); + return m_boundVertexArray; + } + // A target the fill left null (one outside GlobalBufferTargets / BufferBindPointTargets, + // or a read before any fill) is a read the fill cannot have served: the poison Fatal, + // the target in a preceding MGLOG_E. + BindingSlot& GetBufferBindingSlot(BufferTarget target) { + MGP_INPUT_CHECK(MGPipeInputField::GetBufferBindingSlot); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBufferBindingSlot, static_cast(target), 0); + const auto index = static_cast(target); + if (index >= kBufferTargetCount || m_bufferBindingSlot[index] == nullptr) { + MGLOG_E("PipeInputs::GetBufferBindingSlot: no slot for target=%d", static_cast(target)); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetBufferBindingSlot, m_currentVerb); + } + return *m_bufferBindingSlot[index]; + } + BindingSlotRange1D& GetBufferBindingPoint(BufferTarget target, Uint index) { + MGP_INPUT_CHECK(MGPipeInputField::GetBufferBindingPoint); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetBufferBindingPoint, static_cast(target), index); + const auto targetIndex = static_cast(target); + if (targetIndex >= kBufferTargetCount || m_bufferBindingPointBase[targetIndex] == nullptr) { + MGLOG_E("PipeInputs::GetBufferBindingPoint: no binding points for target=%d (index=%u)", + static_cast(target), index); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetBufferBindingPoint, m_currentVerb); + } + // The live storage is Array, N> + // (BufferState.h), so base[index] is the live slot GLContext would hand out. + return m_bufferBindingPointBase[targetIndex][index]; + } + BindingSlot& GetFramebufferBindingSlot(FramebufferTarget target) { + MGP_INPUT_CHECK(MGPipeInputField::GetFramebufferBindingSlot); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetFramebufferBindingSlot, static_cast(target), 0); + const auto index = static_cast(target); + if (index >= kFramebufferTargetCount || m_framebufferBindingSlot[index] == nullptr) { + MGLOG_E("PipeInputs::GetFramebufferBindingSlot: no slot for target=%d", static_cast(target)); + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetFramebufferBindingSlot, m_currentVerb); + } + return *m_framebufferBindingSlot[index]; + } + ImageTextureBinding& GetImageTextureBinding(Int unit) { + MGP_INPUT_CHECK(MGPipeInputField::GetImageTextureBinding); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetImageTextureBinding, static_cast(unit), 0); + if (m_imageTextureBindingBase == nullptr) { + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetImageTextureBinding, m_currentVerb); + } + return m_imageTextureBindingBase[unit]; + } + const ImageTextureBinding& GetImageTextureBinding(Int unit) const { + MGP_INPUT_CHECK(MGPipeInputField::GetImageTextureBinding); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetImageTextureBinding, static_cast(unit), 0); + if (m_imageTextureBindingBase == nullptr) { + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetImageTextureBinding, m_currentVerb); + } + return m_imageTextureBindingBase[unit]; + } + const SharedPtr& GetProgramForDispatch() { + MGP_INPUT_CHECK(MGPipeInputField::GetProgramForDispatch); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetProgramForDispatch, 0, 0); + return m_programForDispatch; + } + const SharedPtr& GetProgramForDraw() { + MGP_INPUT_CHECK(MGPipeInputField::GetProgramForDraw); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetProgramForDraw, 0, 0); + return m_programForDraw; + } + const SharedPtr& GetTransformFeedbackProgram() const { + MGP_INPUT_CHECK(MGPipeInputField::GetTransformFeedbackProgram); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTransformFeedbackProgram, 0, 0); + return m_transformFeedbackProgram; + } + TextureUnit& GetTextureUnitObject(Int unit) { + MGP_INPUT_CHECK(MGPipeInputField::GetTextureUnitObject); + MGP_INPUT_VERIFY_READ(MGPipeInputField::GetTextureUnitObject, static_cast(unit), 0); + if (m_textureUnitBase == nullptr) { + MGPipeInputPoisonFatalForVerb(MGPipeInputField::GetTextureUnitObject, m_currentVerb); + } + return m_textureUnitBase[unit]; + } + + // ---- F: forwarded to the live context (MG_Impl/Pipe/PipeFill.cpp); sticky ---- + // Each takes an argument that is not verb state - a GL name, a lifetime id, a target - + // i.e. it is a lookup or a reverse-channel write, not a state read; there is no value + // the filler could copy and no verb whose fill could make it stale. Phase C replaces + // them with handle tables and callbacks. + // They carry no MGP_INPUT_CHECK / MGP_INPUT_VERIFY_READ (the declared exception to + // P1 brief D4's "every accessor body"): a forward is a live call, not a stored value, + // and InvalidateCompileEnv is reached from backend initialisation before any verb has + // filled, where a check would be Fatal{...@} on every start. Their sticky stamp + // is therefore consulted by no accessor; the tests pin it through + // MGPipeInputFieldIsFresh directly. + SizeT GetBufferBindingPointCount(BufferTarget target) const; + const SharedPtr& GetProgramObject(Uint index); + const SharedPtr& GetTextureObject(Uint index); + Bool HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const; + void InvalidateCompileEnv(); + Bool ValidateProgramName(Uint index) const; + // Dropped with an MGLOG_E_ONCE when no context is live; today's guarded sites never + // reach it without one. + void RecordError(ErrorCode code, UniquePtr info); + + // ---- the storage visitor ---- + // Calls fn(a., b.) for the field's storage and returns its result; returns + // false without calling fn for a forwarded field, which has none. The comparator's + // per-field equality and the verify corruption injector are both one call of this. + template + static Bool VisitStorage(MGPipeInputField field, PipeInputs& a, PipeInputs& b, Fn&& fn) { + switch (field) { +#define MGP_INPUT_VISIT(Field, Member) \ + case MGPipeInputField::Field: \ + return fn(a.Member, b.Member); + MGP_INPUT_STORAGE_LIST(MGP_INPUT_VISIT) +#undef MGP_INPUT_VISIT + default: + return false; + } + } + template + static Bool VisitStorage(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b, Fn&& fn) { + switch (field) { +#define MGP_INPUT_VISIT(Field, Member) \ + case MGPipeInputField::Field: \ + return fn(a.Member, b.Member); + MGP_INPUT_STORAGE_LIST(MGP_INPUT_VISIT) +#undef MGP_INPUT_VISIT + default: + return false; + } + } + + private: + // The one door into the storage from the client side (MG_Impl/Pipe/PipeFill.cpp): + // the filler's per-field copies and stamps, and the verify snapshot. + friend struct MGPipeFillAccess; + // The other door, and the one that exists because of what this block IS after P2: + // the server's working RenderStateParameters. MG_Pipe/PipeApply.cpp scatters + // bind_render_state's and set_dynamic_state's chunks straight into m_renderState, + // which is why DirectGLES' SyncRenderState is not one line changed. It deliberately + // does NOT stamp the poison generations - a stamp says "the filler published this + // for THIS verb", which is the walk's statement, not the applier's. + friend struct MGPipeApplyAccess; + // THE THIRD DOOR, and the one the split phase needed that neither of the two above + // could be: the SERVER's verb-boundary stamp (PipeInputs.cpp). MGPipeApplyAccess + // deliberately does not stamp - see its comment above - and MGPipeFillAccess lives in + // MG_Impl, which is the role the server does not have. So the stamp gets a door of its + // own rather than a relaxation of either existing one. + friend struct MGPipeStampAccess; + + // ---- identity ---- + const void* m_contextIdentity = nullptr; + Bool m_live = false; + MGPipeVerb m_currentVerb = MGPipeVerb::kVerbCount; +#if MOBILEGL_PIPE_POISON + MGPipeFilledState m_filled{}; +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + Bool m_serverStampedVerb = false; +#endif + + // ---- V ---- + Int m_activeTextureUnit = 0; + FloatVec4 m_blendColor{}; + BlendEquation m_blendEquation[kMGMaxDrawBuffers][2]{}; + BlendFactor m_blendFunc[kMGMaxDrawBuffers][4]{}; + Uint m_boundTransformFeedbackName = 0; + SizeT m_touchedBindingPointCount[kBufferTargetCount]{}; + GLenum m_clampReadColor = 0; + FloatVec4 m_clearColor{}; + Float m_clearDepth = 0.f; + Uint32 m_clearStencil = 0; + BoolVec4 m_colorMask[kMGMaxDrawBuffers]{}; + CullFaceMode m_cullFaceMode{}; + CurrentVertexAttributeValue m_currentVertexAttribute[kMaxVertexAttribs]{}; + DepthTestFunc m_depthFunc{}; + Bool m_depthMask = false; + FloatVec2 m_depthRange[kMaxViewports]{}; + Float m_lineWidth = 0.f; + LogicOperation m_logicOp{}; + Int m_maxTouchedTextureUnit = -1; + Float m_minSampleShadingValue = 0.f; + FloatVec2 m_patchDefaultInnerLevel{}; + FloatVec4 m_patchDefaultOuterLevel{}; + Uint m_patchVertices = 0; + Uint m_pipelineStateVersion = 0; + Uint m_renderStateParametersVersion = 0; + PixelStoreParameters m_pixelStore[2]{}; // [0] = pack, [1] = unpack + GLenum m_polygonModeFront = 0; + Float m_polygonOffsetFactor = 0.f; + Float m_polygonOffsetUnits = 0.f; + Uint32 m_primitiveRestartIndex = 0; + ProvokingVertexMode m_provokingVertexMode{}; + RenderStateParameters m_renderState{}; + Uint64 m_samplingResolutionGeneration = 0; + Uint64 m_textureBindGeneration = 0; + Uint64 m_textureContextId = 0; + IntVec4 m_scissorBox{}; + StencilFaceState m_stencil[kStencilFaceCount]{}; + Uint64 m_transformFeedbackCapturedVertices = 0; + Uint64 m_transformFeedbackGeneration = 0; + Uint64 m_transformFeedbackPausedPrimitiveCounter = 0; + Uint64 m_boundTransformFeedbackLifetimeId = 0; + IntVec4 m_viewport{}; + FloatVec4 m_viewportIndexed[kMaxViewports]{}; + Bool m_capability[kCapabilityCount]{}; + IndexedCapabilities m_capabilityIndexed{}; + Bool m_transformFeedbackActive = false; + Bool m_transformFeedbackPaused = false; + + // ---- O ---- + SharedPtr m_boundVertexArray; + BindingSlot* m_bufferBindingSlot[kBufferTargetCount]{}; + BindingSlotRange1D* m_bufferBindingPointBase[kBufferTargetCount]{}; + BindingSlot* m_framebufferBindingSlot[kFramebufferTargetCount]{}; + ImageTextureBinding* m_imageTextureBindingBase = nullptr; + SharedPtr m_programForDispatch; + SharedPtr m_programForDraw; + SharedPtr m_transformFeedbackProgram; + TextureUnit* m_textureUnitBase = nullptr; + }; + + // The single global the backends read through MGB_CTX (ARCHITECTURE.md 9.2). An inline + // variable: no .cpp is needed for the definition. + // + // LEAK-AT-EXIT STORAGE, and it is the same rule Init.cpp and GlobalObjects.cpp state for + // pGLContext and pActiveBackendObject: "a process that exits without eglTerminate simply + // leaks the global singletons to the OS instead of running destructors during static + // teardown". This block breaks that rule if it is a value, because its O-class members + // are SharedPtrs to FRONTEND objects: a VertexArrayObject that the application deleted + // while it was bound has its last reference here, and destroying this block from + // __run_exit_handlers therefore runs ~VertexArrayObject -> ~BufferObject at exit. Those + // destructors are not exit-safe and cannot be made so - they reach the client's slot + // allocator, the resource tracker, the vertex-input emitter, the applier AND, through + // MGPipeApplyResourceDestroy, the backend's own twin tables, deferred-release queue, + // buffer pool and driver entry points, every one of which is either already destroyed or + // about to be. So the reference is never dropped: nothing here can start such a chain. + // A live context releases these SharedPtrs the ordinary way, at the fill point. + // (P3a; the exit-time heap corruption this closes is p3a-results/exit-order-v1.md.) + inline PipeInputs& gPipeInputs = *new PipeInputs(); + + // Every field has storage or is forwarded, and nothing else. +#define MGP_INPUT_COUNT_ONE(Field, Member) +1 + static_assert(0 MGP_INPUT_STORAGE_LIST(MGP_INPUT_COUNT_ONE) + kMGPipeForwardedFieldCount == kMGPipeInputFieldCount, + "MGP_INPUT_STORAGE_LIST plus the seven forwarded fields is not the PipeInputs field set"); +#undef MGP_INPUT_COUNT_ONE + // The docs budget ~20 KB; the block is a few KB. + static_assert(sizeof(PipeInputs) < 20 * 1024, "PipeInputs outgrew its budget"); + +#if MOBILEGL_BUILD_DISAGGREGATED + // ============================================================================ + // P5: the server-side verb stamp, and the counter that sizes what it leaves behind + // ============================================================================ + // + // THE PREREQUISITE NOBODY ELSE OWNS (CONTRACT-P5.md section 3). Nothing stamps the poison + // generations on the applier side today and that is deliberate (see MGPipeApplyAccess' + // comment above: a stamp is the filler's statement, not the applier's). Under split the + // filler is in the other role, so without this every FilledGen[] would stay 0, + // MGPipeInputFieldIsFresh would answer false for EVERYTHING, and a purely server-side read + // would abort on the first field inside SyncRenderState - before any interesting case. + // + // THE RULE, in three lines, and the third one is the load-bearing one: + // + // 1. bump CurrentVerbSerial and set the verb, so a Fatal names it instead of ""; + // 2. stamp every RECORD-SUPPLIED and APPLIER-DERIVED field with the new serial - those + // are exactly the fields the records this verb carried can answer; + // 3. ZERO every BARRIER-PULLED and FATAL field's stamp. + // + // (3) is what makes the instrumentation real. The client's residual fill stamps ALL 63 + // fields at its own verb boundary (PipeFill.cpp step 4), so without the zeroing every + // field would read fresh on the server, `rsp` would be identically 0, and the gate would + // be decoration - the precise "an inproc implementation proves nothing" failure R-2 + // exists to prevent. Zeroing also cancels the sticky exemption for free: generated/ + // PipeFilled.inc tests "never filled" BEFORE it tests sticky, so gen == 0 wins. + // + // The value a BARRIER-PULLED read then gets is still the client's residual fill's, and it + // is still CORRECT - because the verb barrier (R-1) leaves exactly one of the two threads + // runnable. That is the debt, not a bug; `rsp` is its size. + // + // v1 calls this from Server/PipeApplier::StampVerbBoundary. MGPipeVerbForWireOp maps the + // record's op onto a verb and answers kVerbCount for an op that is not verb-shaped, which + // is the case the applier must NOT stamp on: a set_dynamic_state between two draws is not + // a new verb, and stamping there would retire the previous verb's answers early. + // + // TWO THINGS THE CALLER INHERITS AND SHOULD NOT REDISCOVER: + // + // (a) The records BETWEEN two boundaries apply under the earlier boundary's stamp - its + // serial, its class mask and its verb NAME. That is correct today because every + // MGPipeApply* entry point touches gPipeInputs through MGPipeApplyAccess, which + // carries no MGP_INPUT_CHECK; the day one of them calls back into the backend, its + // reads will be judged against a verb they do not belong to. + // (b) The verb a draw record stamps is MGPipeVerb::DrawArrays for ALL TWENTY draw verbs. + // The class mask is right (FillPoints.def puts all twenty in kDraw) and the NAME in a + // Fatal is not: a glDrawElements that aborts will say "@DrawArrays". draw_vbo carries + // no verb id, so fixing it means either a field on the record or a second argument + // here; it is cosmetic for the verdict and misleading for the reader, and it belongs + // with whatever phase widens draw_vbo to the multi-draw family (P8). + void MGPipeServerStampVerbBoundary(MGPipeVerb verb); + // MANDATORY for the applier when it leaves the verb. The client's own MGPipeValidateForVerb + // and MGPipeLeaveVerb call it too, which is enough for inproc and NOT enough for a spawned + // server, where MG_Impl is not in the process - see ServerStampedVerb() above for what + // latching TRUE would do to the sticky forwards. + void MGPipeServerClearVerbBoundary(); + + // `rsp`. Also published per frame through PipeStats::CallClass::ResidualPulls; this is the + // raw count, which exists because PipeStats can be switched off and the exit gate may not + // be. Its value at the end of P5 IS the size of the P6/P7/P8 debt. + // + // PLAIN, NOT ATOMIC, AND THAT IS THE SAME RULING TABLE 3 MAKES FOR gPipeInputS ITSELF + // (CONTRACT-P5.md section 4): the verb barrier leaves at most one of {GL thread, apply + // thread} runnable, so there is one writer at any instant. This counter, m_serverStampedVerb + // and FilledGen[] all rest on that and on nothing else - so MOBILEGL_IPC_VERB_BARRIER=0, + // R-1's negative control, is a data race on all three as well as the correctness failure it + // is there to show. It is expected to be red; it is not expected to be meaningful. + Uint64 MGPipeResidualPullCount(); + void MGPipeResetResidualPullCountForTesting(); + + // The sticky forwards' hook, called from each of the seven bodies in + // MG_Impl/Pipe/PipeFill.cpp. They carry no MGP_INPUT_CHECK at all - the declared exception + // argued at the F-class block above - so freshness can never reach them and the exit gate + // would be structurally blind on the seven fields that hand the server a raw frontend + // object or write into the frontend. This is what puts them in `rsp` and, under + // MOBILEGL_IPC_STRICT_ERRORS=1, makes them Fatal like any other BARRIER-PULLED row. + void MGPipeStickyForwardPull(MGPipeInputField field); +#endif + +#if MOBILEGL_PIPE_VERIFY + // PipeInputs.cpp. Per-field equality for the entry compare (P1 brief D8): V by value + // through G4's MGPipeFieldEqual (bitwise floats, field-wise structs), O by identity, F + // always equal (no storage). + Bool MGPipeInputsFieldEqual(MGPipeInputField field, const PipeInputs& a, const PipeInputs& b); + // PipeInputs.cpp. The entry compare: every field in `mask` of the pushed block against the + // snapshot, first differing field out. Exported from the shared library on purpose - the + // retrace-verify CI job proves it swapped in a verify build by finding this symbol with + // nm -D, so a "green" run against a library without the comparator cannot happen. +#if defined(__GNUC__) || defined(__clang__) + __attribute__((visibility("default"))) +#endif + Bool MGPipeVerifyInputs(const PipeInputs& pushed, const PipeInputs& snapshot, const MGPipeFieldMask& mask, + MGPipeInputField* outField); + // PipeInputs.cpp. Negative control A: perturbs one field's storage (flip a Bool, +1 a + // scalar, ^0x5A the first byte of a struct, flip a pointer's low bits - never + // dereferenced, the snapshot is only ever compared). Returns false for a forwarded field, + // which has nothing to corrupt. + Bool MGPipeApplyVerifyCorruption(PipeInputs& snapshot, MGPipeInputField field); +#endif +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Benchmark/Driver/CMakeLists.txt b/MobileGL/MG_Benchmark/Driver/CMakeLists.txt index ad38be81b..5144f0c97 100644 --- a/MobileGL/MG_Benchmark/Driver/CMakeLists.txt +++ b/MobileGL/MG_Benchmark/Driver/CMakeLists.txt @@ -11,5 +11,51 @@ endif() add_executable(DriverBench DriverBench.c) target_link_libraries(DriverBench PRIVATE dl) +# WHY EVERY ENTRY HERE CARRIES A PASS_REGULAR_EXPRESSION. +# +# DriverBench prints one CSV row per case it ran and exits 0 whatever it ran. Before this, a ctest +# entry naming a case therefore could not answer the only question it exists to ask: an argument +# matching nothing in kBenchCases selected no case, printed only the header row, and still exited +# 0. DriverBench.c now refuses an unknown case name (exit 2), which closes it at the source - but +# the entry must be able to go red for the reason it exists WITHOUT depending on that check +# staying in the binary, so each entry also requires the case's own output row to appear. +# +# The regex is what a healthy run of that case prints and nothing else does: the case name at the +# start of a line, then the frames / ops-per-frame / median-ms / ns-per-op / fps columns +# (run_case()). A rename, a drop from kBenchCases, a boot_egl() failure or +# a crash part-way through the case all remove that row and turn the entry red. +# +# Note that a PASS_REGULAR_EXPRESSION makes ctest ignore the process exit code (cmCTestRunTest: +# success is `retVal == 0 || !RequiredRegularExpressions.empty()`), which is why the row itself +# has to be the evidence rather than a companion to the rc. add_test(NAME DriverBench COMMAND DriverBench draw_tiny) -set_tests_properties(DriverBench PROPERTIES LABELS benchmark) +# draw_tiny's a/ops scale with $DRIVERBENCH_DRAWS (main()), so only the shape of +# the row is pinned here, not the column values. +set_tests_properties(DriverBench PROPERTIES + LABELS benchmark + PASS_REGULAR_EXPRESSION "(^|\n)draw_tiny,[0-9]+,[0-9]+,[0-9.]+,[0-9.]+,[0-9.]+") + +# The Blaze3D blend toggle, as its own entry. +# +# mc_state_toggle is glEnable(GL_BLEND) / glBlendFuncSeparate / glDrawElements / +# glDisable(GL_BLEND) / glDrawElements, 46 times - the measured vanilla-frame rate, and the exact +# shape ROADMAP.md writes down as the microbenchmark P2 owes the GO/NO-GO. It is the workload the +# whole "push at validate, not in the setter" decision was made for: a per-setter design pays for +# every toggle, and a CSO that is minted twice and then reused pays for none of them. +# +# The case has existed in kBenchCases since P0 and nothing ran it, so nothing noticed if it broke. +# Exposing it costs about 1.2 s inside an existing three-minute job, and it means the number the +# P2 report quotes comes from a case CI has been executing all along rather than from a code path +# whose first run is the day it is measured. +# +# Like the entry above, this runs against whatever $DRIVERBENCH_EGL_LIB names (the system driver +# when unset) - the ctest entry is a "does this case still run" gate, not the measurement. The +# measurement is run_driver_bench.sh against each of {native, espryt, magma}. +add_test(NAME DriverBenchStateToggle COMMAND DriverBench mc_state_toggle) +# The ops-per-frame column is pinned to 46 here, unlike the entry above: the mc_* cases are +# excluded from the $DRIVERBENCH_DRAWS scaling on purpose ("the mc_* rates are measured and must +# not move, or the numbers stop being comparable", main()), so 46 toggles per frame +# is part of what "this case still runs" means. Change the workload and this entry says so. +set_tests_properties(DriverBenchStateToggle PROPERTIES + LABELS benchmark + PASS_REGULAR_EXPRESSION "(^|\n)mc_state_toggle,[0-9]+,46,[0-9.]+,[0-9.]+,[0-9.]+") diff --git a/MobileGL/MG_Benchmark/Driver/DriverBench.c b/MobileGL/MG_Benchmark/Driver/DriverBench.c index 6f9667942..8eb20db7d 100644 --- a/MobileGL/MG_Benchmark/Driver/DriverBench.c +++ b/MobileGL/MG_Benchmark/Driver/DriverBench.c @@ -476,6 +476,28 @@ int main(int argc, char** argv) { if (getenv("DRIVERBENCH_FRAMES")) g_frames = atoi(getenv("DRIVERBENCH_FRAMES")); if (getenv("DRIVERBENCH_SPRITES")) g_mixSprites = atol(getenv("DRIVERBENCH_SPRITES")); + /* A requested case name that matches nothing used to select nothing, print the header row and + * exit 0 - so a caller that names a case (run_driver_bench.sh, and the two ctest entries in + * CMakeLists.txt) could not tell "the case ran" from "the case has been renamed or deleted". + * Refuse it here, before any GL work, so the refusal reaches a caller that has no display + * either, and name what does exist so the fix is obvious. */ + int unknownCases = 0; + for (int j = 1; j < argc; ++j) { + int known = 0; + for (int i = 0; i < kBenchCaseCount; ++i) + if (strcmp(argv[j], kBenchCases[i].name) == 0) known = 1; + if (!known) { + fprintf(stderr, "DriverBench: no case named '%s'\n", argv[j]); + unknownCases = 1; + } + } + if (unknownCases) { + fprintf(stderr, "DriverBench: the %d cases in kBenchCases are:\n", kBenchCaseCount); + for (int i = 0; i < kBenchCaseCount; ++i) + fprintf(stderr, " %s\n", kBenchCases[i].name); + return 2; + } + if (boot_egl()) return 1; build_resources(); diff --git a/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp b/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp index fef2853ee..9608a9dce 100644 --- a/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp +++ b/MobileGL/MG_Impl/GLImpl/Buffer/GL_Buffer.cpp @@ -1497,6 +1497,20 @@ namespace MobileGL::MG_Impl::GLImpl { return; } MG_State::pGLContext->TouchBufferBindingPoint(bufferTarget, pointIndex); +#if MOBILEGL_PIPE_PUSH + // P5e (sb, CONTRACT-P5E.md §5.6). THE BIND-POINT GENERATION, and it is bumped HERE - + // before the writes below and after every refusal above - rather than inside + // BindingSlotRange1D, for two reasons. The slot has no back-pointer to the state + // container that owns it, which is the same reason every other aggregate is bumped from + // the entry point; and a bump on a bind that is about to be REJECTED by + // ValidateBufferName is an over-fire the set-hash suppressor absorbs, while a bump + // placed after the writes would be skipped by the `buffer == 0` early return below - + // which is an unbind, the one case a shutter may least afford to miss. + // + // The dirty bits this feeds are 15/16/17 (Tracker.h); before P5e they shuttered on the + // buffer CONTENT aggregate, which no bind has ever moved. + MG_State::pGLContext->NoteBufferBindPointChanged(bufferTarget); +#endif auto& point = MG_State::pGLContext->GetBufferBindingPoint(bufferTarget, pointIndex); SharedPtr bufferObject; @@ -1620,6 +1634,13 @@ namespace MobileGL::MG_Impl::GLImpl { return; } MG_State::pGLContext->TouchBufferBindingPoint(bufferTarget, index); +#if MOBILEGL_PIPE_PUSH + // P5e (sb): the other half of BindBufferBase_State's bump - same generation, same + // placement, same reason. A glBindBufferRange moves the point's EXTENT as well as what + // is bound to it, and the record carries the resolved Offset/Size, so a range change + // with no object change still has to reach the emitter. + MG_State::pGLContext->NoteBufferBindPointChanged(bufferTarget); +#endif auto& point = MG_State::pGLContext->GetBufferBindingPoint(bufferTarget, index); SharedPtr bufferObject; diff --git a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp index d76e80190..232d80fba 100644 --- a/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp +++ b/MobileGL/MG_Impl/GLImpl/Drawing/GL_Drawing.cpp @@ -11,6 +11,16 @@ #include #include #include +#include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#endif +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. P5b t2 converts ONE site in this file - the capture-ownership probe +// in FixupGsStripCaptureOrder - for the reason CONTRACT-P5B.md §6.5 gives. +#include #include "../Getter/GL_Getter.h" namespace MobileGL::MG_Impl::GLImpl { @@ -527,6 +537,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(Clear); MG_Backend::gBackendFunctionsTable.GL.Clear(mask); } @@ -535,6 +546,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElements); MG_Backend::gBackendFunctionsTable.GL.DrawElements(mode, count, type, indices); } @@ -544,6 +556,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElements); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElements(mode, count, type, indices, drawcount); } @@ -553,6 +566,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElementsBaseVertex); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsBaseVertex(mode, count, type, indices, drawcount, basevertex); } @@ -562,6 +576,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawArrays); MG_Backend::gBackendFunctionsTable.GL.DrawArrays(mode, first, count); } @@ -570,6 +585,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawArrays); MG_Backend::gBackendFunctionsTable.GL.MultiDrawArrays(mode, first, count, drawcount); } @@ -579,6 +595,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsBaseVertex); MG_Backend::gBackendFunctionsTable.GL.DrawElementsBaseVertex(mode, count, type, indices, basevertex); } @@ -588,6 +605,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElementsIndirect); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirect(mode, type, indirect, drawcount, stride); } @@ -596,6 +614,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawArraysIndirect); MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirect(mode, indirect, drawcount, stride); } @@ -605,6 +624,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawElementsIndirectCount); MG_Backend::gBackendFunctionsTable.GL.MultiDrawElementsIndirectCount(mode, type, indirect, drawcount, maxdrawcount, stride); } @@ -615,6 +635,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(MultiDrawArraysIndirectCount); MG_Backend::gBackendFunctionsTable.GL.MultiDrawArraysIndirectCount(mode, indirect, drawcount, maxdrawcount, stride); } @@ -625,6 +646,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawRangeElementsBaseVertex); MG_Backend::gBackendFunctionsTable.GL.DrawRangeElementsBaseVertex(mode, start, end, count, type, indices, basevertex); } @@ -635,6 +657,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawRangeElements); MG_Backend::gBackendFunctionsTable.GL.DrawRangeElements(mode, start, end, count, type, indices); } @@ -645,6 +668,8 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_SET_BASE_INSTANCE(baseinstance); + MGP_FILL(DrawElementsInstancedBaseVertexBaseInstance); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertexBaseInstance( mode, count, type, indices, instancecount, basevertex, baseinstance); } @@ -655,6 +680,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsInstancedBaseVertex); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseVertex(mode, count, type, indices, instancecount, basevertex); } @@ -665,6 +691,8 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_SET_BASE_INSTANCE(baseinstance); + MGP_FILL(DrawElementsInstancedBaseInstance); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstancedBaseInstance(mode, count, type, indices, instancecount, baseinstance); } @@ -675,6 +703,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsInstanced); MG_Backend::gBackendFunctionsTable.GL.DrawElementsInstanced(mode, count, type, indices, instancecount); } @@ -683,6 +712,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawElementsIndirect); MG_Backend::gBackendFunctionsTable.GL.DrawElementsIndirect(mode, type, indirect); } void DrawArraysInstancedBaseInstance_Backend(GLenum mode, GLint first, GLsizei count, GLsizei instancecount, @@ -691,6 +721,8 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_SET_BASE_INSTANCE(baseinstance); + MGP_FILL(DrawArraysInstancedBaseInstance); MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstancedBaseInstance(mode, first, count, instancecount, baseinstance); } @@ -700,6 +732,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawArraysInstanced); MG_Backend::gBackendFunctionsTable.GL.DrawArraysInstanced(mode, first, count, instancecount); } @@ -708,6 +741,7 @@ namespace MobileGL::MG_Impl::GLImpl { ZoneScopedC(TRACY_ZONECOLOR_BACKEND); #endif if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DrawArraysIndirect); MG_Backend::gBackendFunctionsTable.GL.DrawArraysIndirect(mode, indirect); } @@ -739,6 +773,7 @@ namespace MobileGL::MG_Impl::GLImpl { // GL 4.3 added both dispatches to the conditional-render set (GL 4.6 core 10.9), which is // exactly what KHR-GL43.compute_shader.conditional-dispatching checks. if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DispatchCompute); dispatchCompute(numGroupsX, numGroupsY, numGroupsZ); } @@ -791,6 +826,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (!ValidateCurrentProgramForCompute(__func__)) return; if (ConditionalRenderDiscardsCommand()) return; + MGP_FILL(DispatchComputeIndirect); dispatchComputeIndirect(indirect); } @@ -812,6 +848,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->SetPatchVertices(static_cast(value)); if (const auto patchParameteri = MG_Backend::gBackendFunctionsTable.GL.PatchParameteri) { + MGP_FILL(PatchParameteri); patchParameteri(pname, value); } } @@ -882,6 +919,7 @@ namespace MobileGL::MG_Impl::GLImpl { MakeUnique("MG_Impl/GLImpl", __func__, "Backend does not support memory barriers.")); return; } + MGP_FILL(MemoryBarrier); memoryBarrier(barriers); } @@ -903,6 +941,7 @@ namespace MobileGL::MG_Impl::GLImpl { MakeUnique("MG_Impl/GLImpl", __func__, "Backend does not support memory barriers.")); return; } + MGP_FILL(MemoryBarrier); memoryBarrier(GL_TEXTURE_FETCH_BARRIER_BIT | GL_FRAMEBUFFER_BARRIER_BIT); } @@ -916,6 +955,7 @@ namespace MobileGL::MG_Impl::GLImpl { "Backend does not support regional memory barriers.")); return; } + MGP_FILL(MemoryBarrierByRegion); memoryBarrierByRegion(barriers); } @@ -1238,6 +1278,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->BeginTransformFeedback(primitiveMode, program); if (const auto beginXfb = MG_Backend::gBackendFunctionsTable.GL.BeginTransformFeedback) { + MGP_FILL(BeginTransformFeedback); beginXfb(primitiveMode); } } @@ -1252,7 +1293,15 @@ namespace MobileGL::MG_Impl::GLImpl { // Only Vulkan-order captures need this. A backend that runs the capture on its // own GL/ES driver (it owns the span, hence the EndTransformFeedback entry) has // already produced GL's vertex order, and reordering it again would corrupt it. - if (MG_Backend::gBackendFunctionsTable.GL.EndTransformFeedback != nullptr) { + // + // P5b t2 (CONTRACT-P5B.md §6.5): UNDER SPLIT THE TABLE THIS USED TO ASK IS THE CLIENT'S + // EMIT TABLE, whose EndTransformFeedback slot t2 just made non-null for every server - + // so the raw null check would answer "the backend owns the capture" even against Magma, + // which registers no XFB slot at all, and would skip a reorder Magma needs. The question + // is about the SERVER's table, so it is answered from the bit the server publishes. + // Under monolith (and in a pull build) this expands to the null check it replaced, + // character for character. + if (MGL_BACKEND_SLOT_CAP(EndTransformFeedback, MG_Pipe::kCapBackendOwnsXfbCapture)) { return; } if (program == nullptr || !program->HasGsTriangleStripCaptureFixup() || inputPrimitives == 0) { @@ -1283,6 +1332,20 @@ namespace MobileGL::MG_Impl::GLImpl { static_cast(bufferIndex)); const auto& buffer = bindingPoint.GetBoundObject(); if (buffer == nullptr) continue; +#if MOBILEGL_BUILD_DISAGGREGATED + // This fixup READS the captured bytes back through the shadow, so it is the one + // consumer that cannot simply inherit the deferral EndTransformFeedback's dropped + // fence introduces. Under split it pays the reconciliation itself, which is the + // same cost the fence used to charge every caller - here charged only to the + // capture shapes that actually need reordering. + // + // TRANSPORT-GATED LIKE EVERY OTHER NEW SITE (D-J). Without the test this fires in + // a build-split lane running MOBILEGL_TRANSPORT=monolith on Magma - whose + // BeginXfbCaptureForDraw does mark the capture targets - where the fence at the + // caller still runs, so the readback it emits is pure new work on the monolith + // path and integration-gpu cannot see it. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) buffer->SyncGpuWrites(); +#endif const Range1D range = bindingPoint.GetRange(); const Uint8* mapped = buffer->MappedData(); if (mapped == nullptr) continue; @@ -1320,17 +1383,37 @@ namespace MobileGL::MG_Impl::GLImpl { // Closed while the capture state is still active: a backend that captures // through its own driver reads the capture program and buffer bindings here. if (const auto endXfb = MG_Backend::gBackendFunctionsTable.GL.EndTransformFeedback) { + MGP_FILL(EndTransformFeedback); endXfb(); } +#if MOBILEGL_BUILD_DISAGGREGATED + // P5 (b1), the second producer the client-side GPU-write set ADDS, and it has to be + // taken HERE - before GLContext::EndTransformFeedback clears the live bindings, since + // a mark taken after it marks nothing. + MG_Remote::Client::MarkEndTransformFeedbackCaptureTargets(); +#endif MG_State::pGLContext->EndTransformFeedback(); // Captured results must be visible to MapBuffer/GetBufferSubData after // End; the capture targets are host-coherent GPU memory, so completing // the GPU work is all that is required. + // + // P5 (b1): UNDER SPLIT THE UNBOUNDED WAIT GOES AND THE MARK ABOVE REPLACES IT. The + // wait exists for one reason - so that a later MapBuffer sees real captured results - + // and that is precisely what m_gpuWritePending says; SyncGpuWrites then pays for it + // once, on the first read that actually wants the bytes, instead of on every + // glEndTransformFeedback. A ~0ull ClientWaitSync on the GL thread is also the one + // shape a verb barrier cannot make cheap, because it is the driver's wait and not the + // barrier's. auto& backendGL = MG_Backend::gBackendFunctionsTable.GL; - if (backendGL.FenceSync && backendGL.ClientWaitSync) { + const Bool waitForTheCapture = + MG_Config::Transport == MG_Config::TransportMode::Monolith; + if (waitForTheCapture && backendGL.FenceSync && backendGL.ClientWaitSync) { + MGP_FILL(FenceSync); if (auto sync = backendGL.FenceSync()) { + MGP_FILL(ClientWaitSync); backendGL.ClientWaitSync(sync, GL_SYNC_FLUSH_COMMANDS_BIT, ~0ull); if (backendGL.DeleteSync) { + MGP_FILL(DeleteSync); backendGL.DeleteSync(sync); } } @@ -1349,6 +1432,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->SetTransformFeedbackPaused(true); if (const auto pauseXfb = MG_Backend::gBackendFunctionsTable.GL.PauseTransformFeedback) { + MGP_FILL(PauseTransformFeedback); pauseXfb(); } } @@ -1363,6 +1447,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->SetTransformFeedbackPaused(false); if (const auto resumeXfb = MG_Backend::gBackendFunctionsTable.GL.ResumeTransformFeedback) { + MGP_FILL(ResumeTransformFeedback); resumeXfb(); } } @@ -1568,6 +1653,7 @@ namespace MobileGL::MG_Impl::GLImpl { continue; } if (const auto deleteXfb = MG_Backend::gBackendFunctionsTable.GL.DeleteTransformFeedback) { + MGP_FILL(DeleteTransformFeedback); deleteXfb(id); } MG_State::pGLContext->MarkTransformFeedbackObjectForDeletion(id); @@ -1599,6 +1685,7 @@ namespace MobileGL::MG_Impl::GLImpl { } MG_State::pGLContext->BindTransformFeedbackObject(id); if (const auto bindXfb = MG_Backend::gBackendFunctionsTable.GL.BindTransformFeedback) { + MGP_FILL(BindTransformFeedback); bindXfb(id); } } diff --git a/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp b/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp index ed94936ac..2275a89d6 100644 --- a/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp +++ b/MobileGL/MG_Impl/GLImpl/Exporting/Definitions.cpp @@ -108,7 +108,18 @@ DECLARE_GL_FUNCTION_HEAD(void, DrawArrays, GLenum mode, GLint first, GLsizei cou DECLARE_GL_FUNCTION_HEAD(void, DrawElements, GLenum mode, GLsizei count, GLenum type, const void* indices) DECLARE_GL_FUNCTION_END_NO_RETURN(void, DrawElements, mode, count, type, indices) DECLARE_GL_FUNCTION_HEAD(void, Enable, GLenum cap) DECLARE_GL_FUNCTION_END_NO_RETURN(void, Enable, cap) DECLARE_GL_FUNCTION_HEAD(void, EnableVertexAttribArray, GLuint index) DECLARE_GL_FUNCTION_END_NO_RETURN(void, EnableVertexAttribArray, index) -MOBILEGL_GL_API void glFinish() { MGLOG_D("Implementing function: %s(...)", __FUNCTION__); } +// P5e (ra), CONTRACT-P5E §2.5. STILL NOTHING ON THE WIRE - Flush and Finish have no record +// (ARCHITECTURE §11) and this stays a no-op on every monolith build. What it is not any more +// is a no-op on a RUN-AHEAD client: "the commands issued so far have completed" is a promise a +// queue the client never waits on can break, and this is the only place an application can ask +// for it. Under lockstep the client had already waited out every command it issued, so the +// call really did have nothing to do; MGPipeClientFinish answers false-cheap there too. +MOBILEGL_GL_API void glFinish() { + MGLOG_D("Implementing function: %s(...)", __FUNCTION__); +#if MOBILEGL_BUILD_DISAGGREGATED + MobileGL::MG_Pipe::MGPipeClientFinish(); +#endif +} MOBILEGL_GL_API void glFlush() { MGLOG_D("Implementing function: %s(...)", __FUNCTION__); } DECLARE_GL_FUNCTION_HEAD(void, FramebufferRenderbuffer, GLenum target, GLenum attachment, GLenum renderbuffertarget, GLuint renderbuffer) DECLARE_GL_FUNCTION_END_NO_RETURN(void, FramebufferRenderbuffer, target, attachment, renderbuffertarget, renderbuffer) DECLARE_GL_FUNCTION_HEAD(void, FramebufferTexture2D, GLenum target, GLenum attachment, GLenum textarget, GLuint texture, GLint level) DECLARE_GL_FUNCTION_END_NO_RETURN(void, FramebufferTexture2D, target, attachment, textarget, texture, level) diff --git a/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp b/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp index 6a6b7bb1c..9f09eafa5 100644 --- a/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp +++ b/MobileGL/MG_Impl/GLImpl/Framebuffer/GL_Framebuffer.cpp @@ -15,6 +15,18 @@ #include #include #include +#include +#if MOBILEGL_BUILD_DISAGGREGATED +#include +#endif +#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 +#endif #include #include #include @@ -612,10 +624,40 @@ 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& 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, GLint dstX1, GLint dstY1, GLbitfield mask, GLenum filter) { + MGP_FILL(BlitFramebuffer); MG_Backend::gBackendFunctionsTable.GL.BlitFramebuffer(srcX0, srcY0, srcX1, srcY1, dstX0, dstY0, dstX1, dstY1, mask, filter); } @@ -629,6 +671,11 @@ 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); } @@ -640,6 +687,10 @@ 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); } @@ -650,6 +701,10 @@ 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); } @@ -660,6 +715,10 @@ 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); } @@ -670,6 +729,10 @@ 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); } @@ -1397,6 +1460,9 @@ namespace MobileGL::MG_Impl::GLImpl { if (texture == 0) { framebufferObject->Detach(attachmentType); +#if MOBILEGL_PIPE_PUSH + PipePublishFramebufferByName(framebufferObject); +#endif return; } @@ -1423,6 +1489,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, @@ -1446,6 +1515,9 @@ namespace MobileGL::MG_Impl::GLImpl { if (texture == 0) { framebufferObject->Detach(attachmentType); +#if MOBILEGL_PIPE_PUSH + PipePublishFramebufferByName(framebufferObject); +#endif return; } @@ -1473,6 +1545,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, @@ -1527,6 +1602,9 @@ namespace MobileGL::MG_Impl::GLImpl { if (texture == 0) { framebufferObject->Detach(attachmentType); +#if MOBILEGL_PIPE_PUSH + PipePublishFramebufferByName(framebufferObject); +#endif return; } @@ -1629,6 +1707,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, @@ -1698,6 +1779,9 @@ namespace MobileGL::MG_Impl::GLImpl { if (renderbuffer == 0) { framebufferObject->Detach(attachmentType); +#if MOBILEGL_PIPE_PUSH + PipePublishFramebufferByName(framebufferObject); +#endif return; } @@ -1707,6 +1791,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& fbo, Bool isDefaultFBO, @@ -1906,6 +1993,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) { @@ -1920,6 +2010,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) { @@ -1929,6 +2022,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 GetFramebufferObjectForNamedClear(GLuint framebuffer, @@ -2729,24 +2825,28 @@ namespace MobileGL::MG_Impl::GLImpl { void ClearBufferfi_Backend(GLenum buffer, GLint drawbuffer, GLfloat depth, GLint stencil) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferfi); MG_Backend::gBackendFunctionsTable.GL.ClearBufferfi(buffer, drawbuffer, depth, stencil); } void ClearBufferfv_Backend(GLenum buffer, GLint drawbuffer, const GLfloat* value) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferfv); MG_Backend::gBackendFunctionsTable.GL.ClearBufferfv(buffer, drawbuffer, value); } void ClearBufferuiv_Backend(GLenum buffer, GLint drawbuffer, const GLuint* value) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferuiv); MG_Backend::gBackendFunctionsTable.GL.ClearBufferuiv(buffer, drawbuffer, value); } void ClearBufferiv_Backend(GLenum buffer, GLint drawbuffer, const GLint* value) { // GL 4.6 core 10.9 makes ClearBuffer* conditional alongside the drawing commands. if (MG_State::pGLContext->ConditionalRenderDiscardsCommands()) return; + MGP_FILL(ClearBufferiv); MG_Backend::gBackendFunctionsTable.GL.ClearBufferiv(buffer, drawbuffer, value); } @@ -2994,7 +3094,20 @@ namespace MobileGL::MG_Impl::GLImpl { } void ReadPixels_Backend(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void* pixels) { + MGP_FILL(ReadPixels); MG_Backend::gBackendFunctionsTable.GL.ReadPixels(x, y, width, height, format, type, pixels); +#if MOBILEGL_BUILD_DISAGGREGATED + // P5 (b1), one of the two producers the client-side GPU-write set ADDS. A read into a + // bound GL_PIXEL_PACK_BUFFER is a GPU write to that buffer exactly as a shader's store + // is, and marking it is what makes the next glMapBuffer / glGetBufferSubData of the + // PBO reconcile. It is a no-op on the monolith path, where the backend still maps the + // PBO and copies it into the shadow inside the call + // (DirectGLES.cpp:10983-10993) - an unconditional stall on every glReadPixels whether + // or not anything ever reads the shadow. Deferring that to the first read that wants + // it is STRICTLY BETTER, which is the only reason a split build is allowed to differ + // here at all. + MG_Remote::Client::MarkReadPixelsPackBuffer(); +#endif } /* @INSERTION_POINT:FUNCTION_IMPLEMENTATION@ */ diff --git a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp index 211f7141a..dec921e9f 100644 --- a/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp +++ b/MobileGL/MG_Impl/GLImpl/Getter/GL_Getter.cpp @@ -30,6 +30,12 @@ #include #include #include +#include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include namespace MobileGL::MG_Impl::GLImpl { // Declared rather than #included from GL_RenderState.h on purpose: that header also declares @@ -1173,6 +1179,7 @@ namespace MobileGL::MG_Impl::GLImpl { : GetMinComputeWorkGroupSize(index); GLint backendValue = 0; if (getIntegeri) { + MGP_FILL(GetIntegeri_v); getIntegeri(target, index, &backendValue); } *data = std::max(backendValue, minimum); @@ -1352,7 +1359,17 @@ namespace MobileGL::MG_Impl::GLImpl { // full 64-bit GPU timestamp survives; LWJGL reads it this way. Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { - if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + // glGetInteger64v(GL_TIMESTAMP). GetGpuTimestampNs is class C - a LIVE GPU + // timestamp is not a static property, so R-15 does not reach it - and the + // documented answer when it is unavailable is 0 (BackendObject.h:192), which + // is correct rather than merely quiet. kCapTimerQuery is the published bit. + // + // The POINTER-valued macro, so the init-statement below is unchanged in a pull + // build and G1 cannot see this edit: the Bool-valued spelling moved this + // function by -150 bytes for no behavioural reason at all. + if (const auto getGpuTimestampNs = + MGL_BACKEND_SLOT_PTR_CAP(GetGpuTimestampNs, MG_Pipe::kCapTimerQuery)) { + MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } } @@ -2262,7 +2279,17 @@ namespace MobileGL::MG_Impl::GLImpl { case GL_TIMESTAMP: { Int64 timestamp = 0; if (!MG_Config::Features.DisableTimerQuery) { - if (const auto getGpuTimestampNs = MG_Backend::gBackendFunctionsTable.GL.GetGpuTimestampNs) { + // glGetInteger64v(GL_TIMESTAMP). GetGpuTimestampNs is class C - a LIVE GPU + // timestamp is not a static property, so R-15 does not reach it - and the + // documented answer when it is unavailable is 0 (BackendObject.h:192), which + // is correct rather than merely quiet. kCapTimerQuery is the published bit. + // + // The POINTER-valued macro, so the init-statement below is unchanged in a pull + // build and G1 cannot see this edit: the Bool-valued spelling moved this + // function by -150 bytes for no behavioural reason at all. + if (const auto getGpuTimestampNs = + MGL_BACKEND_SLOT_PTR_CAP(GetGpuTimestampNs, MG_Pipe::kCapTimerQuery)) { + MGP_FILL(GetGpuTimestampNs); timestamp = getGpuTimestampNs(); } } diff --git a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp index e3bee89bb..dfbe2ca4d 100644 --- a/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp +++ b/MobileGL/MG_Impl/GLImpl/Program/GL_Program.cpp @@ -21,6 +21,7 @@ #include #include #include +#include namespace MobileGL::MG_Impl::GLImpl { // The flattened uniform type these helpers used to take as a raw glslang::TType* @@ -3398,6 +3399,7 @@ namespace MobileGL::MG_Impl::GLImpl { "Backend does not support shader storage block binding.")); return; } + MGP_FILL(ShaderStorageBlockBinding); shaderStorageBlockBinding(program, blockName.c_str(), storageBlockBinding); } diff --git a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp index bcdc199a7..64c990fc9 100644 --- a/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp +++ b/MobileGL/MG_Impl/GLImpl/Query/GL_Query.cpp @@ -12,6 +12,11 @@ #include #include #include +#include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read, whatever class the slot itself is in. +// The two macros carry that rule; in a pull build each expands to exactly the check it replaced. +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -164,6 +169,7 @@ namespace MobileGL::MG_Impl::GLImpl { void ResetQueryObjectLocked(QueryObject* queryObject) { if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -178,6 +184,7 @@ namespace MobileGL::MG_Impl::GLImpl { void EndTimeElapsedQueryLocked(QueryObject* queryObject) { const auto endTimeElapsedQuery = MG_Backend::gBackendFunctionsTable.GL.EndTimeElapsedQuery; if (endTimeElapsedQuery && queryObject->backendHandle) { + MGP_FILL(EndTimeElapsedQuery); endTimeElapsedQuery(queryObject->backendHandle); } queryObject->active = false; @@ -257,6 +264,7 @@ namespace MobileGL::MG_Impl::GLImpl { } Uint64 result = 0; const auto getQueryResult64 = MG_Backend::gBackendFunctionsTable.GL.GetQueryResult64; + MGP_FILL(GetQueryResult64); if (queryObject->backendHandle && getQueryResult64 && !getQueryResult64(queryObject->backendHandle, /*wait=*/false, &result)) { // Not ready. The whole point of the no-wait form is that the caller's @@ -271,6 +279,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -286,6 +295,7 @@ namespace MobileGL::MG_Impl::GLImpl { return true; } const auto isQueryResultAvailable = MG_Backend::gBackendFunctionsTable.GL.IsQueryResultAvailable; + MGP_FILL(IsQueryResultAvailable); outValue = (!isQueryResultAvailable || isQueryResultAvailable(queryObject->backendHandle)) ? 1 : 0; return true; } @@ -297,6 +307,7 @@ namespace MobileGL::MG_Impl::GLImpl { Uint64 result = 0; if (queryObject->backendHandle) { const auto getQueryResult64 = MG_Backend::gBackendFunctionsTable.GL.GetQueryResult64; + MGP_FILL(GetQueryResult64); if (getQueryResult64 && !getQueryResult64(queryObject->backendHandle, /*wait=*/true, &result)) { // The backend could not produce the result YET (e.g. a @@ -317,6 +328,7 @@ namespace MobileGL::MG_Impl::GLImpl { // query degrades to a zero result); the backend handle is // consumed and the value cached for later reads. if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -422,6 +434,7 @@ namespace MobileGL::MG_Impl::GLImpl { queryObject->target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) { if (const auto endOcclusionQuery = MG_Backend::gBackendFunctionsTable.GL.EndOcclusionQuery; endOcclusionQuery && queryObject->backendHandle) { + MGP_FILL(EndOcclusionQuery); endOcclusionQuery(queryObject->backendHandle); } queryObject->active = false; @@ -441,6 +454,7 @@ namespace MobileGL::MG_Impl::GLImpl { } if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -468,7 +482,7 @@ namespace MobileGL::MG_Impl::GLImpl { const Bool isOcclusionQuery = (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) && - MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); const Bool isPipelineStatisticsQuery = IsPipelineStatisticsQueryTarget(target); if (target != GL_TIME_ELAPSED && !isTransformFeedbackQuery && !isOcclusionQuery && !isPipelineStatisticsQuery) { @@ -518,20 +532,27 @@ namespace MobileGL::MG_Impl::GLImpl { } else if (isTransformFeedbackQuery) { // Prefer real GPU transform-feedback queries (exact with geometry shaders); // the CPU accounting delta stays as the fallback when the backend lacks them. + const Bool xfbQuerySupported = + MGL_BACKEND_SLOT_CAP(BeginXfbPrimitivesQuery, MG_Pipe::kCapXfbPrimitivesQuery); const auto beginXfbPrimitivesQuery = MG_Backend::gBackendFunctionsTable.GL.BeginXfbPrimitivesQuery; + MGP_FILL(BeginXfbPrimitivesQuery); queryObject->backendHandle = - beginXfbPrimitivesQuery ? beginXfbPrimitivesQuery(target == GL_PRIMITIVES_GENERATED) : nullptr; + xfbQuerySupported ? beginXfbPrimitivesQuery(target == GL_PRIMITIVES_GENERATED) : nullptr; queryObject->counterSnapshot = TransformFeedbackCounterForTarget(target); queryObject->accountedCaptureDrawSnapshot = MG_State::pGLContext->GetTransformFeedbackAccountedCaptureDraws(); queryObject->geometryCaptureDrawSnapshot = MG_State::pGLContext->GetTransformFeedbackGeometryCaptureDraws(); } else if (isOcclusionQuery) { + MGP_FILL(BeginOcclusionQuery); queryObject->backendHandle = MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery(); } else { + const Bool timerQuerySupported = + MGL_BACKEND_SLOT_CAP(BeginTimeElapsedQuery, MG_Pipe::kCapTimerQuery); const auto beginTimeElapsedQuery = MG_Backend::gBackendFunctionsTable.GL.BeginTimeElapsedQuery; + MGP_FILL(BeginTimeElapsedQuery); queryObject->backendHandle = - (!TimerQueryDisabled() && beginTimeElapsedQuery) ? beginTimeElapsedQuery() : nullptr; + (!TimerQueryDisabled() && timerQuerySupported) ? beginTimeElapsedQuery() : nullptr; } activeQueryId = id; } @@ -542,7 +563,7 @@ namespace MobileGL::MG_Impl::GLImpl { const Bool isOcclusionQuery = (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) && - MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); const Bool isPipelineStatisticsQuery = IsPipelineStatisticsQueryTarget(target); if (target != GL_TIME_ELAPSED && !isTransformFeedbackQuery && !isOcclusionQuery && !isPipelineStatisticsQuery) { @@ -579,6 +600,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (isTransformFeedbackQuery) { if (queryObject->backendHandle) { if (const auto endXfbPrimitivesQuery = MG_Backend::gBackendFunctionsTable.GL.EndXfbPrimitivesQuery) { + MGP_FILL(EndXfbPrimitivesQuery); endXfbPrimitivesQuery(queryObject->backendHandle); } } @@ -588,6 +610,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (!queryObject->backendHandle || PrefersCpuTransformFeedbackResult(queryObject)) { if (queryObject->backendHandle) { if (const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } queryObject->backendHandle = nullptr; @@ -604,6 +627,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (isOcclusionQuery) { if (const auto endOcclusionQuery = MG_Backend::gBackendFunctionsTable.GL.EndOcclusionQuery; endOcclusionQuery && queryObject->backendHandle) { + MGP_FILL(EndOcclusionQuery); endOcclusionQuery(queryObject->backendHandle); } queryObject->active = false; @@ -641,7 +665,11 @@ namespace MobileGL::MG_Impl::GLImpl { ResetQueryObjectLocked(queryObject); // discard any previous result queryObject->target = target; - const auto queryCounterTimestamp = MG_Backend::gBackendFunctionsTable.GL.QueryCounterTimestamp; + const Bool timerQuerySupported = + MGL_BACKEND_SLOT_CAP(QueryCounterTimestamp, MG_Pipe::kCapTimerQuery); + const auto queryCounterTimestamp = + timerQuerySupported ? MG_Backend::gBackendFunctionsTable.GL.QueryCounterTimestamp : nullptr; + MGP_FILL(QueryCounterTimestamp); queryObject->backendHandle = (!TimerQueryDisabled() && queryCounterTimestamp) ? queryCounterTimestamp() : nullptr; queryObject->ended = true; @@ -765,12 +793,13 @@ namespace MobileGL::MG_Impl::GLImpl { } if (target == GL_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED || target == GL_ANY_SAMPLES_PASSED_CONSERVATIVE) { - const Bool occlusionSupported = MG_Backend::gBackendFunctionsTable.GL.BeginOcclusionQuery != nullptr; + const Bool occlusionSupported = MGL_BACKEND_SLOT_CAP(BeginOcclusionQuery, MG_Pipe::kCapOcclusionQuery); *params = occlusionSupported ? (target == GL_SAMPLES_PASSED ? 32 : 1) : 0; return; } const Bool timerTarget = target == GL_TIME_ELAPSED || target == GL_TIMESTAMP; const auto isTimerQuerySupported = MG_Backend::gBackendFunctionsTable.GL.IsTimerQuerySupported; + MGP_FILL(IsTimerQuerySupported); const Bool supported = timerTarget && !TimerQueryDisabled() && isTimerQuerySupported && isTimerQuerySupported(); *params = supported ? 64 : 0; @@ -912,6 +941,7 @@ namespace MobileGL::MG_Impl::GLImpl { const auto deleteBackendQuery = MG_Backend::gBackendFunctionsTable.GL.DeleteBackendQuery; for (const auto& [_, queryObject] : orphans) { if (deleteBackendQuery && queryObject->backendHandle) { + MGP_FILL(DeleteBackendQuery); deleteBackendQuery(queryObject->backendHandle); } delete queryObject; diff --git a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp index 39da96d5c..97199d96b 100644 --- a/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp +++ b/MobileGL/MG_Impl/GLImpl/Sync/GL_Sync.cpp @@ -9,6 +9,12 @@ #include "GL_Sync.h" #include #include +#include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include namespace MobileGL::MG_Impl::GLImpl { namespace { @@ -55,7 +61,11 @@ namespace MobileGL::MG_Impl::GLImpl { auto* syncObject = new SyncObject; syncObject->condition = condition; syncObject->flags = flags; + // P5b: FenceSync is now a class-B emitter under split. Its server sink keeps the + // backend's optional/null-native fallback; the client must reach the wire first. + // This is the same pointer expression MGL_BACKEND_SLOT_PTR_LOCAL had in a pull build. if (const auto backendFenceSync = MG_Backend::gBackendFunctionsTable.GL.FenceSync) { + MGP_FILL(FenceSync); syncObject->backendHandle = backendFenceSync(); } const GLsync handle = reinterpret_cast(syncObject); @@ -94,6 +104,7 @@ namespace MobileGL::MG_Impl::GLImpl { if (!backendClientWaitSync || !syncObject->backendHandle) { return GL_ALREADY_SIGNALED; // legacy always-signaled fallback } + MGP_FILL(ClientWaitSync); return backendClientWaitSync(syncObject->backendHandle, flags, timeout); } @@ -119,6 +130,7 @@ namespace MobileGL::MG_Impl::GLImpl { } const auto backendWaitSync = MG_Backend::gBackendFunctionsTable.GL.WaitSync; if (backendWaitSync && syncObject->backendHandle) { + MGP_FILL(WaitSync); backendWaitSync(syncObject->backendHandle, flags, timeout); } } @@ -139,6 +151,7 @@ namespace MobileGL::MG_Impl::GLImpl { } const auto backendDeleteSync = MG_Backend::gBackendFunctionsTable.GL.DeleteSync; if (backendDeleteSync && syncObject->backendHandle) { + MGP_FILL(DeleteSync); backendDeleteSync(syncObject->backendHandle); } delete syncObject; @@ -174,6 +187,7 @@ namespace MobileGL::MG_Impl::GLImpl { break; case GL_SYNC_STATUS: { const auto backendGetSyncStatus = MG_Backend::gBackendFunctionsTable.GL.GetSyncStatus; + MGP_FILL(GetSyncStatus); const Bool signaled = !backendGetSyncStatus || !syncObject->backendHandle || backendGetSyncStatus(syncObject->backendHandle); value = signaled ? GL_SIGNALED : GL_UNSIGNALED; @@ -227,6 +241,7 @@ namespace MobileGL::MG_Impl::GLImpl { const auto backendDeleteSync = MG_Backend::gBackendFunctionsTable.GL.DeleteSync; for (const auto& [_, syncObject] : orphans) { if (backendDeleteSync && syncObject->backendHandle) { + MGP_FILL(DeleteSync); backendDeleteSync(syncObject->backendHandle); } delete syncObject; diff --git a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp index 647f2c07b..9acd5a4e1 100644 --- a/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp +++ b/MobileGL/MG_Impl/GLImpl/Texture/GL_Texture.cpp @@ -30,6 +30,15 @@ #include #include #include +#include +// CONTRACT-P5.md §7 / ID-14: a null check on a GLFunctionsTable slot may not survive into the +// client under split - it becomes a caps-mirror read. SlotCaps.h carries the rule and the test +// that decides which of its two spellings a site takes; in a pull build both expand to exactly +// the check they replaced. +#include +// 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 namespace MobileGL::MG_Impl::GLImpl { static SharedPtr nullTextureObject; @@ -1076,6 +1085,7 @@ namespace MobileGL::MG_Impl::GLImpl { Vector scratch(static_cast(width) * static_cast(height) * bytesPerTexel); { ScopedNeutralPackState neutralPack; + MGP_FILL(ReadPixels); MG_Backend::gBackendFunctionsTable.GL.ReadPixels(x, y, width, height, format, type, scratch.data()); } @@ -1335,6 +1345,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& textureObject, GLenum pname, @@ -1413,6 +1447,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& textureObject, @@ -1619,6 +1677,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void GenerateMipmap_Backend(GLenum target) { + MGP_FILL(GenerateMipmap); MG_Backend::gBackendFunctionsTable.GL.GenerateMipmap(target); } @@ -2102,6 +2161,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) { @@ -4024,6 +4107,7 @@ namespace MobileGL::MG_Impl::GLImpl { void CopyTexSubImage2D_Backend(GLenum target, GLint level, GLint xoffset, GLint yoffset, GLint x, GLint y, GLsizei width, GLsizei height) { + MGP_FILL(CopyTexSubImage2D); MG_Backend::gBackendFunctionsTable.GL.CopyTexSubImage2D(target, level, xoffset, yoffset, x, y, width, height); } @@ -4040,6 +4124,7 @@ namespace MobileGL::MG_Impl::GLImpl { "Backend does not support image-to-image copies.")); return; } + MGP_FILL(CopyImageSubData); copyImageSubData(src, srcTarget, srcLevel, srcX, srcY, srcZ, dst, dstTarget, dstLevel, dstX, dstY, dstZ, srcWidth, srcHeight, srcDepth); } @@ -4461,6 +4546,7 @@ namespace MobileGL::MG_Impl::GLImpl { void CopyTexImage2D_Backend(GLenum target, GLint level, GLenum internalformat, GLint x, GLint y, GLsizei width, GLsizei height, GLint border) { + MGP_FILL(CopyTexImage2D); MG_Backend::gBackendFunctionsTable.GL.CopyTexImage2D(target, level, internalformat, x, y, width, height, border); } @@ -5071,6 +5157,7 @@ namespace MobileGL::MG_Impl::GLImpl { } void GetTexImage_Backend(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { + MGP_FILL(GetTexImage); MG_Backend::gBackendFunctionsTable.GL.GetTexImage(target, level, format, type, pixels); } @@ -6452,7 +6539,8 @@ namespace MobileGL::MG_Impl::GLImpl { GLenum type, GLsizei bufSize, void* pixels, const char* caller) { if (MG_Backend::pActiveBackendObject != nullptr && MG_Backend::pActiveBackendObject->GetBackendType() == BackendType::DirectVulkan && - MG_Backend::gBackendFunctionsTable.GL.GetTextureImage != nullptr) { + MGL_BACKEND_SLOT_LOCAL(GetTextureImage)) { + MGP_FILL(GetTextureImage); MG_Backend::gBackendFunctionsTable.GL.GetTextureImage(textureObject, uploadTarget, level, format, type, bufSize, pixels); return; @@ -6657,6 +6745,17 @@ namespace MobileGL::MG_Impl::GLImpl { MG_State::pGLContext->GetImageTextureBinding(static_cast(unit)) .Bind(textureObject, level, layered, layer, access, format); MG_State::pGLContext->NoteTextureUnitTouched(static_cast(unit)); +#if MOBILEGL_PIPE_PUSH + // AND THE IMAGE-UNIT MARK BESIDE IT. The line above moves the TEXTURE-unit high-water + // mark, which is a different array: a reader that needs "the highest image unit ever + // bound" cannot take it from there without either over-walking (a texture bind at unit + // 31 with no image bound anywhere) or, worse, under-walking if that line ever moves. The + // split client's per-draw writable-image sweep is that reader + // (MG_Remote/Client/GpuWritePending.cpp). Push builds only, so the pull build's bytes do + // not move (G1). + MG_State::pGLContext->NoteImageUnitTouched(static_cast(unit)); +#endif + MGP_FILL(BindImageTexture); bindImageTexture(unit, texture, level, layered, layer, access, format); } @@ -6712,7 +6811,7 @@ namespace MobileGL::MG_Impl::GLImpl { void GetTexImage(GLenum target, GLint level, GLenum format, GLenum type, GLvoid* pixels) { if (!GetTexImage_State(target, level, format, type, pixels)) return; - if (MG_Backend::gBackendFunctionsTable.GL.GetTexImage != nullptr) { + if (MGL_BACKEND_SLOT_LOCAL(GetTexImage)) { GetTexImage_Backend(target, level, format, type, pixels); return; } diff --git a/MobileGL/MG_Impl/Pipe/CompositeResolver.h b/MobileGL/MG_Impl/Pipe/CompositeResolver.h new file mode 100644 index 000000000..ccaaf91d7 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/CompositeResolver.h @@ -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 + +// 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 +#include +#include +#include +#include + +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(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 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 diff --git a/MobileGL/MG_Impl/Pipe/CsoCache.h b/MobileGL/MG_Impl/Pipe/CsoCache.h new file mode 100644 index 000000000..fb159353d --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/CsoCache.h @@ -0,0 +1,206 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/CsoCache.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The render-state CSO cache (ARCHITECTURE.md 4.5.2 / 5.3, P2 brief D7). +// +// THE LOOKUP, and the first step is the whole point: +// 1. m_pipelineStateVersion (widened) did not move -> reuse the last handle. ZERO hashing, +// zero probing, and nothing is emitted unless m_version also moved. That is the steady +// state of every frame, and it is why the tracker asks the cache at all only when the +// dirty walk says the pipeline version moved. +// 2. moved -> hash the 396 pipeline bytes, probe, and on a hit CONFIRM WITH A MEMCMP +// before reusing the handle. ARCHITECTURE.md 4.1 says content addressing on an +// xxHash; a bare 64-bit equality would let a collision alias two different render +// states onto one CSO, which is silent wrong pixels with no gate that can see it. +// Mesa's cso_cache memcmps for the same reason. The memcmp only ever runs on a +// pipeline-version change, i.e. never in the steady state. +// 3. miss -> mint a slot, emit create_render_state with every pipeline chunk, then bind. +// +// CAPACITY 64 (ROADMAP.md P2). 64 x (8 + 8 + 396 + 8) = about 26 KB per context. ROADMAP.md +// open question 4 says 64 is provisional and the counters retune it at P13; this ships 64 +// and publishes the mint / bind / evict counters that retune reads. +// +// THE NEGATIVE CONTROL. kMGPipeBehaviourNoCsoContentAddressing (bit 63 of the runtime +// MOBILEGL_PIPE_PUSH bitmask) turns off the PROBE and the handle reuse, not the records: +// every pipeline-version change then mints a fresh CSO, binds it and evicts, which is +// precisely "whole-block content addressing" and reproduces the regression +// RenderState.h records. It is what separates "push is slower" from "the CSO design is +// slower", and CsoContentAddressingScenario (package E) is the always-on ctest that stops +// the switch from rotting. +// +// Header-only for the same ownership reason as Tracker.h: the root CMakeLists.txt that +// would name a new .cpp is package A's and is frozen behind the p2/contract tag. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + + inline constexpr SizeT kMGPipeCsoCacheCapacity = 64; + + class MGPipeCsoCache { + public: + struct Counters { + Uint64 Mints = 0; // create_render_state emissions + // bind_render_state emissions, mint or reuse. Counted in Acquire because Acquire + // has exactly ONE caller (PipeFill.cpp's EmitRenderState) and that caller binds + // immediately after every call - so "acquisitions" and "binds" are the same + // number, and counting it here keeps the count from depending on an emitter + // remembering to tick it. mints/binds is the cache's hit rate and it is the + // number the CSO content-addressing negative control moves. + Uint64 Binds = 0; + Uint64 Hits = 0; // a probe that found a live entry and passed the memcmp + Uint64 Collisions = 0; // a hash hit the memcmp REJECTED - the reason it exists + Uint64 Evictions = 0; // LRU evictions, each one a delete_render_state + }; + + // The handle for `params`' pipeline subset. Mints and emits create_render_state on a + // miss; emits delete_render_state for whatever it evicts to make room. `payloadBytes` + // accumulates what went on the wire, for PipeStats::RecordDrawPayloadBytes. + MGPipeHandle Acquire(const RenderStateParameters& params, Uint64& payloadBytes) { + Array bytes; + MGPipeGatherPipelineBytes(params, bytes.data()); + ++m_counters.Binds; + + const Bool contentAddressed = + (MG_Config::Features.PipePush & kMGPipeBehaviourNoCsoContentAddressing) == 0; + if (contentAddressed) { + const Uint64 hash = s_hashForTest != nullptr ? s_hashForTest(bytes.data()) + : MGPipeHashPipelineBytes(bytes.data()); + for (SizeT i = 0; i < m_entries.size(); ++i) { + if (m_entries[i].Hash != hash) continue; + if (std::memcmp(m_entries[i].Bytes.data(), bytes.data(), bytes.size()) != 0) { + // A 64-bit collision between two DIFFERENT render states. Reusing the + // handle here would render one state with the other's pipeline, so the + // entry is dropped and the caller mints - correctness first, and the + // counter says how often it happened. + ++m_counters.Collisions; + Evict(i); + break; + } + m_entries[i].LastUsed = ++m_clock; + ++m_counters.Hits; + return m_entries[i].Cso; + } + return Mint(hash, bytes, payloadBytes); + } + // Content addressing OFF: never probe, always mint. The records still exist, so + // the arm differs from the default one in exactly one thing - whether a handle is + // reused - which is what makes it a control rather than a different design. + return Mint(0, bytes, payloadBytes); + } + + // Context teardown, a server reset, a unit test's fixture. Emits nothing: the applier + // is reset alongside, and a delete for a record that is about to be dropped anyway + // would be a wire message with no reader. + void Reset() { + for (auto& entry : m_entries) MGPipeSlots().Free(MGPipeKind::RenderStateCso, entry.Cso); + m_entries.clear(); + m_clock = 0; + } + + void ResetCounters() { m_counters = Counters{}; } + + SizeT Size() const { return m_entries.size(); } + const Counters& GetCounters() const { return m_counters; } + + // TEST SEAM, and it is here because the thing it tests cannot be reached any other + // way. A 64-bit collision between two DIFFERENT render states is silent wrong pixels + // and it is exactly what the memcmp confirm above exists to stop, so + // CsoCacheTest.HashCollisionDoesNotAliasTwoStates has to be able to make one happen. + // Null in every real build - one never-taken, perfectly-predicted branch on a path + // that runs only when the pipeline version moved, i.e. never in the steady state. + using HashForTestFn = Uint64 (*)(const void* pipelineBytes); + inline static HashForTestFn s_hashForTest = nullptr; + + private: + struct Entry { + Uint64 Hash = 0; + Uint64 LastUsed = 0; + MGPipeHandle Cso = kMGPipeNullHandle; + Array Bytes{}; + }; + + MGPipeHandle Mint(Uint64 hash, const Array& bytes, + Uint64& payloadBytes) { + if (m_entries.size() >= kMGPipeCsoCacheCapacity) { + SizeT victim = 0; + for (SizeT i = 1; i < m_entries.size(); ++i) { + if (m_entries[i].LastUsed < m_entries[victim].LastUsed) victim = i; + } + Evict(victim); + } + + const MGPipeHandle cso = MGPipeSlots().Allocate(MGPipeKind::RenderStateCso); + MGPRenderStateDesc desc{}; + desc.Cso = cso; + desc.BaseCso = kMGPipeNullHandle; + // A brand-new CSO names every pipeline chunk; the incremental form against a + // BaseCso is what the applier's assertion allows and P3 will use once a CSO is + // minted from a neighbour rather than from nothing. + desc.ChunkMask = kAllPipelineChunks; + desc.Blob.Size = kMGPipePipelineChunkBytes; + MGPipeRouteCreateRenderState(desc, bytes.data()); + payloadBytes += sizeof(MGPRenderStateDesc) + kMGPipePipelineChunkBytes; + + Entry entry; + entry.Hash = hash; + entry.LastUsed = ++m_clock; + entry.Cso = cso; + entry.Bytes = bytes; + m_entries.push_back(entry); + + ++m_counters.Mints; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoMints, 1); + } + return cso; + } + + void Evict(SizeT index) { + MGPHandleOnly handle{}; + handle.Handle = m_entries[index].Cso; + handle.Kind = static_cast(MGPipeKind::RenderStateCso); + MGPipeRouteDeleteRenderState(handle); + MGPipeSlots().Free(MGPipeKind::RenderStateCso, m_entries[index].Cso); + m_entries[index] = m_entries.back(); + m_entries.pop_back(); + ++m_counters.Evictions; + } + + static constexpr Uint32 kAllPipelineChunks = + static_cast((Uint64{1} << kMGPipePipelineChunkCount) - 1); + + Vector m_entries; + Uint64 m_clock = 0; + Counters m_counters; + }; + + // The monolith's one cache, held beside the tracker. A Vector scan rather than a hash + // map on purpose: 64 entries of Uint64 is a handful of cache lines, it is probed only + // when the pipeline version moved, and it keeps the eviction order in the same array as + // the content - a map would need a second structure to answer "which is oldest". + inline MGPipeCsoCache& MGPipeCsoCacheInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the + // rule covers every MGPipe process singleton, not only the ones on today's death + // paths. + static MGPipeCsoCache* cache = new MGPipeCsoCache(); + return *cache; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/FramebufferEmit.h b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h new file mode 100644 index 000000000..887ff886d --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/FramebufferEmit.h @@ -0,0 +1,660 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/FramebufferEmit.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The CLIENT side of P4a's framebuffer family: set_framebuffer_state, emitted at the validate +// point once per bound TARGET that moved, or once with Target = Both when the two bindings +// name the same object. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT, and the +// split is the whole reason it exists this early. MG_Impl/Pipe/PipeFill.cpp is the contract +// package's for the entire phase - it carries Coverage.def's enum-coupled block, the validate +// point and the death helpers - so the emitter package must not edit it. What it edits instead +// is this header: the emitter's BODY, and the value of kMGPipeWiredFramebufferSubsystem below. +// That is what makes "no file is touched twice by two packages" structural rather than a +// convention, and it is what the bb2a236d semantic-merge trap taught (two branches green +// separately, the integrated tree not compiling). +// +// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state: the root +// CMakeLists.txt that would name a new .cpp is the contract package's and is frozen behind the +// tag. MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace MobileGL::MG_Pipe { + + // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the four per-family + // constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives the + // emitters their bodies, with no file touched twice - and a Coverage.def row can never + // silently drop a field on the floor before the call that carries it exists. + // + // TURNING IT ON RETIRES NO PULL. GetFramebufferBindingSlot is the family's one + // Coverage.def emitted row and PipeFill.cpp's EmittedCallSuppliesTheWholeField answers + // FALSE for it, with the reason: the field's storage is a BindingSlot - + // 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(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(texture->GetFormat()); + surface.Layered = attachment.IsLayered() ? 1 : 0; + surface.Level = static_cast(std::max(attachment.GetTextureLevel(), 0)); + surface.Layer = static_cast(std::max(attachment.GetTextureLayer(), 0)); + surface.UploadTarget = static_cast(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(texture->GetTarget()); + return surface; + } + const auto& renderbuffer = attachment.GetRenderbuffer(); + surface.Kind = kMGPipeSurfaceKindRenderbuffer; + surface.InternalFormat = static_cast(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(static_cast(buffer) - static_cast(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(buffer) - static_cast(FramebufferAttachmentType::Color0) < + static_cast(kMGPipeMaxColorAttachments); + } + + // --------------------------------------------------------------------------------- + // D-C4: ContentHash, and the one input it must not swallow + // --------------------------------------------------------------------------------- + // + // XXH64 over the WHOLE record with ContentHash itself zeroed, computed field-wise into a + // zero-initialised staging copy so that no padding byte can enter the hash. Two jobs: the + // server's render-pass memo key, and this client's emission suppressor. + // + // IT MUST COVER Fbo. A recycled framebuffer handle whose successor happens to carry an + // identical attachment set would otherwise be suppressed against its predecessor; Fbo + // carries Gen, so it cannot be. + // + // IT MUST COVER DrawBuffers[8], and this is the trap worth naming. The backend derives the + // fragColor BROADCAST COUNT from the draw-buffer array, and it does that at the verb, from + // the framebuffer state it then holds, precisely so a program can relink inside the same + // draw. A hash that did not cover the array would let a suppressed set_framebuffer_state + // mean "the draw buffers did not move" when they had, and the shader would be specialised + // for the previous output shape. With the array in the hash, a suppression provably means + // the array did not move, which provably means the broadcast count did not move. + inline void MGPipeCopySurfaceForHash(MGPSurface& dst, const MGPSurface& src) { + dst.Res = src.Res; + dst.InternalFormat = src.InternalFormat; + dst.Kind = src.Kind; + dst.Layered = src.Layered; + dst.Level = src.Level; + dst.Layer = src.Layer; + dst.UploadTarget = src.UploadTarget; + // MANDATORY, not optional: TextureTarget is a PipeFields.def row now, so a + // field-wise copy that skipped it would suppress a record whose only moved field is + // the attachment's texture target - and that field decides three of the four + // cross-object masks. + dst.TextureTarget = src.TextureTarget; + } + + inline Uint64 MGPipeFramebufferStateContentHash(const MGPFramebufferState& state) { + MGPFramebufferState staging{}; + staging.Fbo = state.Fbo; + for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) { + MGPipeCopySurfaceForHash(staging.Color[i], state.Color[i]); + } + MGPipeCopySurfaceForHash(staging.Depth, state.Depth); + MGPipeCopySurfaceForHash(staging.Stencil, state.Stencil); + MGPipeCopySurfaceForHash(staging.ReadSurface, state.ReadSurface); + for (SizeT i = 0; i < kMGPipeMaxColorAttachments; ++i) { + staging.DrawBuffers[i] = state.DrawBuffers[i]; + } + staging.Width = state.Width; + staging.Height = state.Height; + staging.Layers = state.Layers; + staging.Samples = state.Samples; + staging.FixedSampleLocations = state.FixedSampleLocations; + staging.IsDefault = state.IsDefault; + staging.Complete = state.Complete; + staging.Target = state.Target; + // staging.ContentHash stays 0 - that is the whole point. + return XXH64(&staging, sizeof(staging), 0); + } + + // --------------------------------------------------------------------------------- + // The emitter + // --------------------------------------------------------------------------------- + + class MGPipeFramebufferEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + using FramebufferObject = MG_State::GLState::FramebufferObject; + using FramebufferAttachmentType = MobileGL::FramebufferAttachmentType; + + // The handle for `fbo`. kMGPipeDefaultFramebuffer ({0,1}) for the default framebuffer, + // which is what retires the four pDefaultFramebufferInfo->defaultFBO identity + // comparisons into an ordinary handle compare; a client-minted {slot, gen} otherwise. + // + // Minted, never gated: a framebuffer handle is CLIENT state and costs one free-list pop. + static MGPipeHandle HandleFor(const FramebufferObject& fbo) { + if (fbo.IsDefaultFramebuffer()) return kMGPipeDefaultFramebuffer; + return MGPipeSlots().Acquire(MGPipeKind::Framebuffer, fbo.GetLifetimeId()); + } + + // Returns the bytes that went on the wire, for the per-draw payload histogram. + Uint64 EmitFramebufferState(GLContext& ctx) { + if (!MGPipeFramebufferSubsystemEnabled()) return 0; + const auto& drawFbo = ctx.GetFramebufferBindingSlot(MobileGL::FramebufferTarget::Draw).GetBoundObject(); + const auto& readFbo = ctx.GetFramebufferBindingSlot(MobileGL::FramebufferTarget::Read).GetBoundObject(); + if (!drawFbo && !readFbo) return 0; + + // ONE OBJECT BOUND TO BOTH TARGETS IS ONE RECORD WITH Target = Both, and that is + // not an optimisation: Espryt's "same FBO as draw" skip is the habitat of the + // read-buffer defect class, and a record that says which target it describes turns + // "apply the draw buffers only for the draw target" from call-site discipline into + // a one-line test on the far side. + const Bool shared = drawFbo && readFbo && drawFbo.get() == readFbo.get(); + + MGPFramebufferState drawState{}; + MGPFramebufferState readState{}; + Bool drawOk = false; + Bool readOk = false; + if (shared) { + drawOk = BuildFramebufferState(*drawFbo, MGPipeFramebufferTarget::Both, drawState); + } else { + if (drawFbo) { + drawOk = BuildFramebufferState(*drawFbo, MGPipeFramebufferTarget::Draw, drawState); + } + if (readFbo) { + readOk = BuildFramebufferState(*readFbo, MGPipeFramebufferTarget::Read, readState); + } + } + if (!drawOk && !readOk) return 0; + + // THE SUPPRESSOR SLOT IS FED THE COMBINED ANSWER and the per-target latches decide + // which of the two records actually goes out. The slot exists so that + // InvalidateAll() on a fresh context reaches this family like every other, and so + // that "nothing moved" costs one compare rather than two. + const Uint64 drawHash = drawOk ? drawState.ContentHash : 0; + const Uint64 readHash = readOk ? readState.ContentHash : 0; + const Uint64 combined = + MGPipeMixShutter(MGPipeMixShutter(drawHash, readHash), shared ? 1u : 0u); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetFramebufferState, + combined)) { + return 0; + } + + Uint64 bytes = 0; + if (shared) { + if (drawOk && (drawHash != m_lastEmitted[kDraw] || drawHash != m_lastEmitted[kRead])) { + bytes += Emit(drawState); + m_lastEmitted[kDraw] = drawHash; + m_lastEmitted[kRead] = drawHash; + } + return bytes; + } + if (drawOk && drawHash != m_lastEmitted[kDraw]) { + bytes += Emit(drawState); + m_lastEmitted[kDraw] = drawHash; + } + if (readOk && readHash != m_lastEmitted[kRead]) { + bytes += Emit(readState); + m_lastEmitted[kRead] = readHash; + } + return bytes; + } + + // ID-19(c): EVERY DSA ENTRY POINT THAT HANDS A FRAMEBUFFER TO THE SERVER BY NAME IS + // PRECEDED BY A RECORD FOR IT, and that is the phase's main correction rather than a + // nicety. With only the two BOUND-target records, glClearNamedFramebufferfv(fbo) on an + // unbound fbo made the backend mint a fresh driver framebuffer with NO ATTACHMENTS, + // find no record for it, decline, and issue the clear against it anyway - + // GL_INVALID_FRAMEBUFFER_OPERATION and nothing cleared, where the legacy arm cleared + // correctly (esprytobj C-1). + // + // THE TARGET IS Named ONLY WHEN THE OBJECT IS BOUND TO NEITHER BINDING. A record always + // writes FramebufferRecords[Fbo.Slot]; Draw/Read/Both ADDITIONALLY set the bound + // handle(s). So handing a currently-bound framebuffer a Named record would overwrite + // the bound record's Target with one that says "no binding" while BoundFramebuffer + // still names it, and the server would read a record whose Target contradicts the + // binding it is resolved through. Re-asserting the binding the object already has is + // free (the content hash suppresses it) and keeps the two consistent. + // + // Returns the bytes that went on the wire. + Uint64 EmitFramebufferByName(const FramebufferObject& fbo) { + if (!MGPipeFramebufferSubsystemEnabled()) return 0; + MGPipeFramebufferTarget target = MGPipeFramebufferTarget::Named; + const Bool boundToDraw = IsBoundTo(fbo, MobileGL::FramebufferTarget::Draw); + const Bool boundToRead = IsBoundTo(fbo, MobileGL::FramebufferTarget::Read); + if (boundToDraw && boundToRead) { + target = MGPipeFramebufferTarget::Both; + } else if (boundToDraw) { + target = MGPipeFramebufferTarget::Draw; + } else if (boundToRead) { + target = MGPipeFramebufferTarget::Read; + } + + MGPFramebufferState state{}; + if (!BuildFramebufferState(fbo, target, state)) return 0; + + // THE SUPPRESSOR IS KEYED BY THE FRAMEBUFFER THE RECORD NAMES, never by one global + // slot (MGPipeTypes.h states the rule): two different objects' Named records in a + // row must both go out, and a Named record must never be suppressed against the + // same object's bound record or the reverse. Target is a ContentHash input, so the + // second half holds by construction; the per-object table is what buys the first. + // The two BOUND latches stay what they are - "does the server's draw/read binding + // already hold this record" - and a bound-target emission from here consults them, + // because a rebind of an unchanged object must still move the binding. + if (target == MGPipeFramebufferTarget::Named) { + NamedEntry& entry = NamedEntryFor(state.Fbo); + if (entry.Has && entry.Gen == state.Fbo.Gen && entry.LastHash == state.ContentHash) { + return 0; + } + const Uint64 bytes = Emit(state); + entry.Has = true; + entry.Gen = state.Fbo.Gen; + entry.LastHash = state.ContentHash; + return bytes; + } + if (target == MGPipeFramebufferTarget::Both) { + if (state.ContentHash == m_lastEmitted[kDraw] && state.ContentHash == m_lastEmitted[kRead]) { + return 0; + } + const Uint64 bytes = Emit(state); + m_lastEmitted[kDraw] = state.ContentHash; + m_lastEmitted[kRead] = state.ContentHash; + return bytes; + } + const SizeT slot = target == MGPipeFramebufferTarget::Read ? kRead : kDraw; + if (state.ContentHash == m_lastEmitted[slot]) return 0; + const Uint64 bytes = Emit(state); + m_lastEmitted[slot] = state.ContentHash; + return bytes; + } + + // ---- what a unit case reads. The emitter builds INTO these and hands the applier the + // same objects, so "what was emitted" costs no copy. ---- + // ---- the death half (P4a final review C-2) ---- + // + // Called by the contract's death helper before the slot is freed (there is no wire + // delete for this kind, D-I2, so this is the only client-side thing a framebuffer's + // death has to do). The per-object Named latch is the entry: a recycled handle's Gen + // already refuses the stale latch, so this is hygiene rather than a fix - the rule + // (ID-8) is that whatever mints a handle retires everything it keeps under it at the + // death, and every P4a kind takes the same shape. Gen-keyed for a late notice. + void NoteFramebufferDied(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_named.size()) return; + if (m_named[slot].Gen == handle.Gen) m_named[slot] = NamedEntry{}; + } + // "Does this emitter hold a Named-record latch for this handle at its generation." + Bool NamedRecordIsLatched(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_named.size()) return false; + return m_named[slot].Has && m_named[slot].Gen == handle.Gen; + } + + const MGPFramebufferState& LastDraw() const { return m_lastDraw; } + const MGPFramebufferState& LastRead() const { return m_lastRead; } + const MGPFramebufferState& LastNamed() const { return m_lastNamed; } + Uint64 EmissionCount() const { return m_emissions; } + Uint64 RefusedCount() const { return m_refusals; } + + // A fresh context: what the server has is no longer what this emitter last sent. Only + // LATCHES reset here - MGPipeApplierReset clears the applier's DrawFramebuffer and + // ReadFramebuffer working state, so these mirrors have to go with them or the first + // emission after a make-current would be suppressed as unchanged and the server would + // draw into the previous context's framebuffer. The suppressor slot is invalidated by + // the validate point's own InvalidateAll(), beside this call. + void Reset() { + m_lastEmitted[kDraw] = 0; + m_lastEmitted[kRead] = 0; + // The per-object latch goes too, and the safe direction is why: MGPipeApplierReset + // keeps FramebufferRecords standing (they are object state, ID-19(b)) but + // ReleaseObjectRecords clears the whole table, and this emitter cannot tell the two + // scopes apart from here. Keeping a latch across a table that may have been dropped + // would suppress the one record that had to go out; dropping it costs one extra + // 304-byte record per named framebuffer after a context switch. + m_named.clear(); + } + + void ResetCounters() { m_emissions = m_refusals = 0; } + + void ResetForTest() { + Reset(); + ResetCounters(); + m_lastDraw = MGPFramebufferState{}; + m_lastRead = MGPFramebufferState{}; + m_lastNamed = MGPFramebufferState{}; + } + + private: + static constexpr SizeT kDraw = 0; + static constexpr SizeT kRead = 1; + + Uint64 Emit(const MGPFramebufferState& state) { + if (state.Target == static_cast(MGPipeFramebufferTarget::Named)) { + m_lastNamed = state; + } else if (state.Target == static_cast(MGPipeFramebufferTarget::Read)) { + m_lastRead = state; + } else { + m_lastDraw = state; + if (state.Target == static_cast(MGPipeFramebufferTarget::Both)) m_lastRead = state; + } + MGPipeRouteSetFramebufferState(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(FramebufferAttachmentType::Color0) + + static_cast(kMGPipeMaxColorAttachments); + point <= static_cast(FramebufferAttachmentType::ColorMax); ++point) { + if (fbo.GetAttachment(static_cast(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(FramebufferAttachmentType::Color0), + static_cast(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(tokens[i]) - + static_cast(FramebufferAttachmentType::Color0), + static_cast(i), static_cast(kMGPipeMaxColorAttachments)); + ++m_refusals; + return false; + } + } + + out = MGPFramebufferState{}; + out.Fbo = HandleFor(fbo); + out.Target = static_cast(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( + static_cast(FramebufferAttachmentType::Color0) + + static_cast(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(kMGPipeBindDepthStencil) + : static_cast(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(std::clamp(size.x(), 0, 0xFFFF)); + out.Height = static_cast(std::clamp(size.y(), 0, 0xFFFF)); + out.Layers = static_cast( + attachment.IsLayered() ? std::clamp(size.z(), 1, 0xFFFF) : 1); + if (attachment.IsTexture()) { + const auto& texture = attachment.GetTexture(); + out.Samples = static_cast(std::max(texture->GetSamples(), 0)); + out.FixedSampleLocations = texture->HasFixedSampleLocations() ? 1 : 0; + } else { + out.Samples = static_cast( + std::max(attachment.GetRenderbuffer()->GetSamples(), 0)); + out.FixedSampleLocations = 1; + } + found = true; + } + } + if (found) return; + out.Width = static_cast(std::clamp(fbo.GetDefaultWidth(), 0, 0xFFFF)); + out.Height = static_cast(std::clamp(fbo.GetDefaultHeight(), 0, 0xFFFF)); + out.Layers = static_cast(std::clamp(fbo.GetDefaultLayers(), 0, 0xFFFF)); + out.Samples = static_cast(std::clamp(fbo.GetDefaultSamples(), 0, 0xFFFF)); + out.FixedSampleLocations = fbo.GetDefaultFixedSampleLocations() ? 1 : 0; + } + + Array 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 m_named; + MGPFramebufferState m_lastDraw{}; + MGPFramebufferState m_lastRead{}; + MGPFramebufferState m_lastNamed{}; + Uint64 m_emissions = 0; + Uint64 m_refusals = 0; + }; + + inline MGPipeFramebufferEmitter& MGPipeFramebufferEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the + // rule covers every MGPipe process singleton, not only the ones a frontend destructor + // reaches today, and it is what keeps exit() out of a torn-down pipe. + static MGPipeFramebufferEmitter* emitter = new MGPipeFramebufferEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/ImageEmit.h b/MobileGL/MG_Impl/Pipe/ImageEmit.h new file mode 100644 index 000000000..9b18ef535 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/ImageEmit.h @@ -0,0 +1,198 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/ImageEmit.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The CLIENT side of set_shader_images, the third of P4a's kVarTail unit sets. It rides +// SamplerEmit.h's subsystem bit (kMGPipeWiredSamplerSubsystem): one family, one A/B. +// +// TWO INVARIANTS THAT MUST SURVIVE INTO THE BODY, and they are the kind an optimisation +// deletes: +// 1. THE HIGH-WATER-ZERO EARLY-OUT. An image high-water mark of 0 emits nothing, BEFORE any +// hash - that is what makes every Minecraft draw pay one integer test for a feature it +// does not use. +// 2. THE SWEEP'S GATE IS KEYED ON FRONTEND GENERATIONS AND DELIBERATELY NOT ON A BACKEND +// RE-MINT COUNTER. A texture bound ONLY to an image unit is re-minted INSIDE the sweep, +// so a server-side epoch would be bumped after the gate had already declined. The +// client's bit-14 shutter is Mix(Mix(textureContent, textureParams), programImageUnitVersion) +// - all three FRONTEND counters - so the property is preserved by construction, and it is +// written here because it is invisible from the shutter itself. +// +// The record carries the APPLICATION's format and access; the bind-format recast (a GL_RG32F +// bind is INVALID_VALUE on 19 of 26 non-core formats on Adreno) and the buffer-texture split +// view stay SERVER-side and unchanged. ContentHash therefore has to cover InternalFormat and +// Access as well as the binding, because the format the shader was built against is live +// glBindImageTexture state and the format-less image bake keys on it. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + + // 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. + // + // P5e (CONTRACT-P5E.md §1, ruling 16) MOVED THE NUMBERS OUT FROM UNDER THIS FUNCTION and + // left the GLenum half here, where the GLenum is. The three values are + // MGPipeValueTypes.h's MGPipeImageAccess, which the server decodes through, so the encode + // and the decode are one table instead of two literals that happened to agree. It is also + // why the function is a free function now rather than a private static: the unit case that + // pins the three constants has to reach BOTH halves (MG_Test/Pipe/ImageEmitTest.cpp). + inline Uint32 MGPipeEncodeImageAccess(GLenum access) { + switch (access) { + case GL_READ_ONLY: + return kMGPipeImageAccessReadOnly; + case GL_WRITE_ONLY: + return kMGPipeImageAccessWriteOnly; + case GL_READ_WRITE: + return kMGPipeImageAccessReadWrite; + default: + MOBILEGL_ASSERT(false, "glBindImageTexture access 0x%x is not one of the three GL names", + static_cast(access)); + return kMGPipeImageAccessReadOnly; + } + } + + // 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(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 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. + // + // AND IT IS STILL NOT TextureState's MARK, EVEN THOUGH ONE NOW EXISTS. P5d round 3 + // (package C) added TextureState::NoteImageUnitTouched - a push-build-only image-unit + // high-water mark, fed by glBindImageTexture - for the split client's per-draw + // GPU-write sweep. That mark answers "which units could hold a binding"; the window + // below answers the strictly narrower "which units could a SHADER READ", which is what + // makes the zero early-out fire for an application that binds an image no shader names. + // Swapping one for the other would widen this emitter for no record's benefit, and the + // paragraph that used to stand here - "adding a counter to TextureState would resize the + // pull build's object, which G1 forbids outright" - is answered by that mark being + // compiled only into push builds, where the object is already a different size. + // + // So the window is derived instead, from the one thing that decides whether an image + // unit can matter at all: the highest image unit the CURRENT PROGRAM names, memoised + // per program state in SamplerEmit.h's shared inversion, UNIONED with a sticky mark of + // every unit this emitter has already described. A program with no image uniforms + // gives MaxImageUnit == -1 and, with nothing sticky yet, a window of 0 - which is the + // zero early-out, taken BEFORE any hash and before any 192-entry walk, exactly as + // property 1 requires. The mark is sticky so that a program which stops naming a unit + // does not silently stop describing it: the window only grows, and shrinking it is how + // a stale binding would become invisible to the server. + Uint64 EmitShaderImages(GLContext& ctx) { + const auto& program = ctx.GetProgramForDraw(); + const auto& resolution = MGPipeProgramOpaqueUnitsShared().For(program.get()); + const Uint32 programWindow = + resolution.MaxImageUnit < 0 ? 0u : static_cast(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(unit)); + MGPImageView& entry = m_entries[unit]; + entry = MGPImageView{}; + entry.Unit = unit; + entry.Res = binding.Texture ? MGPipeSlots().Acquire(MGPipeKind::Texture, + binding.Texture->GetLifetimeId()) + : kMGPipeNullHandle; + // D-A4: a texture named in an emitted MGPImageView is SHADER-IMAGE-bound from + // then on - the bit ImageBindableHint is derived from. The bind itself noted it + // first (TextureState.h, so the hint precedes the first sync); this is the + // letter of the rule and a one-compare early-out once the bit is set. + if (!MGPipeHandleIsNull(entry.Res)) { + MGPipeNoteTextureBoundAs(entry.Res, static_cast(kMGPipeBindShaderImage)); + } + // THE APPLICATION's format and access, verbatim. The bind-format recast and the + // buffer-texture split view are server-side and stay there; so does + // SupportsLayeredImageBinding's rule, which asks the BACKEND target after + // MapToBackendTextureTarget and forces layer to 0 for a non-layerable one - + // Adreno took a stray layer index literally. A client that pre-applied any of + // that would be answering a driver question from the wrong side. + entry.InternalFormat = static_cast(binding.Format); + entry.Layer = static_cast(binding.Layer); + entry.Level = static_cast(binding.Level); + entry.Layered = binding.Layered != GL_FALSE ? 1 : 0; + entry.Access = static_cast(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; + MGPipeRouteSetShaderImages(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(count) * sizeof(MGPImageView); + } + + // The validate point's FreshlyPrimed arm. A fresh context is a fresh set of image + // bindings, so the sticky window starts over; the suppressor slot this set latches is + // invalidated beside this call. There is no record half here at all - set_shader_images + // is pure working state and mints no object of its own. + void Reset() { m_window = 0; } + + void ResetCounters() { m_imageSets = 0; } + + const MGPShaderImages& LastShaderImages() const { return m_lastImages; } + const Array& LastImageViews() const { return m_entries; } + Uint64 ImageSetCount() const { return m_imageSets; } + Uint32 Window() const { return m_window; } + + private: + Array m_entries{}; + MGPShaderImages m_lastImages{}; + Uint32 m_window = 0; + Uint64 m_imageSets = 0; + }; + + inline MGPipeImageEmitter& MGPipeImageEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason; heap-constructed and + // intentionally leaked at exit, like every other MGPipe process singleton. + static MGPipeImageEmitter* emitter = new MGPipeImageEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.cpp b/MobileGL/MG_Impl/Pipe/PipeFill.cpp new file mode 100644 index 000000000..72ff9c249 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/PipeFill.cpp @@ -0,0 +1,3801 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/PipeFill.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// The client side of the PipeInputs block (ARCHITECTURE.md 9.2 phase A): the only place in +// the push arm that reads MG_State::pGLContext. Holds the per-verb filler, the F-class +// forwarders, IsLive, the MOBILEGL_PIPE_POISON_OMIT knob and - in a verify build - the +// second arm (SnapshotFromGLContext), the entry compare, the compare-at-read hook and the +// MOBILEGL_PIPE_VERIFY_CORRUPT / _FATAL knobs. Compiled only under MOBILEGL_PIPE_PUSH +// (CMakeLists.txt appends it to SOURCE_FILES there). +#include +#include +// C-1: the vertex-elements CSO's death path raises the backend notice from here, between the +// applier's delete and the slot free, so that the whole order lives in one place. +#include +#include +#include +// P4a's five client emitters. This translation unit is the ONLY one that includes them in the +// library, exactly as it is for Tracker.h, CsoCache.h, ResourceTracker.h and VertexInputEmit.h +// - all of them header-only for the same ownership reason. Each carries its family's +// kMGPipeWired*Subsystem constant, so the bit that switches a family on is added by the commit +// that gives that family's emitters their bodies, and no two packages ever edit one file. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +// R-8 (c1): the client's liveness gates read the caps mirror, never MGPipeGetResourceOps(). +// Behind the build option for G1's reason - nothing under MG_Remote may be reachable from a +// pull build - and every use below is additionally gated on the resolved TRANSPORT, because +// build-split runs MOBILEGL_TRANSPORT=monolith in every unit and integration-gpu lane and those +// lanes must keep answering exactly what they answered before. +#include +#include +// P5c gt (CONTRACT-P5C §6 layer 2, audit A1): the client-side gPipeInputs check consults +// InBarrierWait() and ApplyThreadIsInsideApplier() - both live here. +#include +// P5c ev (CONTRACT-P5C §4.2): RecordError's transport arm posts kEventGlError through the +// server session, and InvalidateCompileEnv's forward is deleted with an active transport. +#include +#endif + +#include +#include +#include +#include + +namespace MobileGL::MG_Pipe { + using GLContext = MG_State::GLState::GLContext; + + // The one door into PipeInputs' storage on the client side. A struct rather than a + // list of friend functions so the header names exactly one friend. + struct MGPipeFillAccess { + // Copies ONE field's storage out of the live context by calling the GLContext + // accessor of the same name (P1 brief D4: no derivation logic is re-implemented + // here, which is what keeps the copy semantically identical by construction). + // A forwarded field has no storage and copies nothing. + // The two doors the P2 emission step needs into PipeInputs' storage. They exist + // only for ApplierDerivesRenderStateFields' one-shot probe below; nothing on the hot + // path writes through them. + static RenderStateParameters& RenderStateOf(PipeInputs& inputs) { return inputs.m_renderState; } + static Uint32& ClearStencilOf(PipeInputs& inputs) { return inputs.m_clearStencil; } + // Read-only, and it exists for one thing: after set_vertex_attrib_defaults goes out, + // the emitter compares what the applier left here against what the frontend holds + // (EmitVertexAttribDefaults). Reading it through this door rather than through the + // accessor is deliberate - the accessor is poison-checked and this is a fill-time + // read, not a backend read. + static const PipeInputs::CurrentVertexAttributeValue* VertexAttribDefaultsOf(const PipeInputs& inputs) { + return inputs.m_currentVertexAttribute; + } + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (ra, CONTRACT-P5E §2.3). THE FOUR O-CLASS ROWS, AND ONLY THEY: these are the only + // members of this block that own a frontend object rather than point into one, so they + // are the only ones through which the apply thread can become a last owner. The raw + // pointer rows (the binding-slot bases) are borrowed from a live GLContext and own + // nothing, so releasing them would buy no lifetime and lose the monolith's arms. + static void ReleaseObjectPins(PipeInputs& inputs) { + inputs.m_boundVertexArray.reset(); + inputs.m_programForDispatch.reset(); + inputs.m_programForDraw.reset(); + inputs.m_transformFeedbackProgram.reset(); + } +#endif + + static void CopyField(PipeInputs& dst, GLContext& ctx, MGPipeInputField field) { + using F = MGPipeInputField; + using MG_State::GLState::BufferBindPointTargets; + using MG_State::GLState::GlobalBufferTargets; + switch (field) { + case F::GetActiveTextureUnit: + dst.m_activeTextureUnit = ctx.GetActiveTextureUnit(); + break; + case F::GetBlendColor: + dst.m_blendColor = ctx.GetBlendColor(); + break; + case F::GetBlendEquationIndexed: + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + ctx.GetBlendEquationIndexed(i, dst.m_blendEquation[i][0], dst.m_blendEquation[i][1]); + } + break; + case F::GetBlendFuncIndexed: + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + ctx.GetBlendFuncIndexed(i, dst.m_blendFunc[i][0], dst.m_blendFunc[i][1], dst.m_blendFunc[i][2], + dst.m_blendFunc[i][3]); + } + break; + case F::GetBoundTransformFeedbackName: + dst.m_boundTransformFeedbackName = ctx.GetBoundTransformFeedbackName(); + break; + case F::GetBoundVertexArray: + dst.m_boundVertexArray = ctx.GetBoundVertexArray(); + break; + case F::GetBufferBindingSlot: + // Every global target has a slot; Index stays null - GLContext resolves it + // through the bound VAO's element-buffer slot (Core.cpp), a derivation no + // FillPoints.def row can copy - and a read of it is the poison Fatal in the + // accessor. No backend reads it today (every slot read is DrawIndirect, + // DispatchIndirect, Parameter or PixelPack). + for (const auto target : GlobalBufferTargets) { + dst.m_bufferBindingSlot[static_cast(target)] = &ctx.GetBufferBindingSlot(target); + } + break; + case F::GetBufferBindingPoint: + // The live storage is Array, N> + // (BufferState.h), so the address of point 0 is the base of that target's row. + for (const auto target : BufferBindPointTargets) { + dst.m_bufferBindingPointBase[static_cast(target)] = &ctx.GetBufferBindingPoint(target, 0); + } + break; + case F::GetTouchedBufferBindingPointCount: + for (const auto target : BufferBindPointTargets) { + dst.m_touchedBindingPointCount[static_cast(target)] = + ctx.GetTouchedBufferBindingPointCount(target); + } + break; + case F::GetClampReadColor: + dst.m_clampReadColor = ctx.GetClampReadColor(); + break; + case F::GetClearColor: + dst.m_clearColor = ctx.GetClearColor(); + break; + case F::GetClearDepth: + dst.m_clearDepth = ctx.GetClearDepth(); + break; + case F::GetClearStencil: + dst.m_clearStencil = ctx.GetClearStencil(); + break; + case F::GetColorMaskIndexed: + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + dst.m_colorMask[i] = ctx.GetColorMaskIndexed(i); + } + break; + case F::GetCullFaceMode: + dst.m_cullFaceMode = ctx.GetCullFaceMode(); + break; + case F::GetCurrentVertexAttribute: + for (Uint i = 0; i < PipeInputs::kMaxVertexAttribs; ++i) { + dst.m_currentVertexAttribute[i] = ctx.GetCurrentVertexAttribute(i); + } + break; + case F::GetDepthFunc: + dst.m_depthFunc = ctx.GetDepthFunc(); + break; + case F::GetDepthMask: + dst.m_depthMask = ctx.GetDepthMask(); + break; + case F::GetDepthRangeIndexed: + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + dst.m_depthRange[i] = ctx.GetDepthRangeIndexed(i); + } + break; + case F::GetFramebufferBindingSlot: + for (SizeT i = 0; i < PipeInputs::kFramebufferTargetCount; ++i) { + dst.m_framebufferBindingSlot[i] = + &ctx.GetFramebufferBindingSlot(static_cast(i)); + } + break; + case F::GetImageTextureBinding: + // Array (TextureState.h): unit 0's + // address is the base. + dst.m_imageTextureBindingBase = &ctx.GetImageTextureBinding(0); + break; + case F::GetLineWidth: + dst.m_lineWidth = ctx.GetLineWidth(); + break; + case F::GetLogicOp: + dst.m_logicOp = ctx.GetLogicOp(); + break; + case F::GetMaxTouchedTextureUnit: + dst.m_maxTouchedTextureUnit = ctx.GetMaxTouchedTextureUnit(); + break; + case F::GetMinSampleShadingValue: + dst.m_minSampleShadingValue = ctx.GetMinSampleShadingValue(); + break; + case F::GetPatchDefaultInnerLevel: + dst.m_patchDefaultInnerLevel = ctx.GetPatchDefaultInnerLevel(); + break; + case F::GetPatchDefaultOuterLevel: + dst.m_patchDefaultOuterLevel = ctx.GetPatchDefaultOuterLevel(); + break; + case F::GetPatchVertices: + dst.m_patchVertices = ctx.GetPatchVertices(); + break; + case F::GetPipelineStateVersion: + dst.m_pipelineStateVersion = ctx.GetPipelineStateVersion(); + break; + case F::GetPixelStoreParameters: + dst.m_pixelStore[0] = ctx.GetPixelStoreParameters(false); + dst.m_pixelStore[1] = ctx.GetPixelStoreParameters(true); + break; + case F::GetPolygonModeFront: + dst.m_polygonModeFront = ctx.GetPolygonModeFront(); + break; + case F::GetPolygonOffsetFactor: + dst.m_polygonOffsetFactor = ctx.GetPolygonOffsetFactor(); + break; + case F::GetPolygonOffsetUnits: + dst.m_polygonOffsetUnits = ctx.GetPolygonOffsetUnits(); + break; + case F::GetPrimitiveRestartIndex: + dst.m_primitiveRestartIndex = ctx.GetPrimitiveRestartIndex(); + break; + case F::GetProgramForDispatch: + dst.m_programForDispatch = ctx.GetProgramForDispatch(); + break; + case F::GetProgramForDraw: + dst.m_programForDraw = ctx.GetProgramForDraw(); + break; + case F::GetProvokingVertexMode: + dst.m_provokingVertexMode = ctx.GetProvokingVertexMode(); + break; + case F::GetRenderStateParameters: + dst.m_renderState = ctx.GetRenderStateParameters(); + break; + case F::GetRenderStateParametersVersion: + dst.m_renderStateParametersVersion = ctx.GetRenderStateParametersVersion(); + break; + case F::GetSamplingResolutionGeneration: + dst.m_samplingResolutionGeneration = ctx.GetSamplingResolutionGeneration(); + break; + case F::GetScissorBox: + dst.m_scissorBox = ctx.GetScissorBox(); + break; + case F::GetStencilState: + dst.m_stencil[0] = ctx.GetStencilState(StencilFace::Front); + dst.m_stencil[1] = ctx.GetStencilState(StencilFace::Back); + break; + case F::GetTextureBindGeneration: + dst.m_textureBindGeneration = ctx.GetTextureBindGeneration(); + break; + case F::GetTextureContextId: + dst.m_textureContextId = ctx.GetTextureContextId(); + break; + case F::GetTextureUnitObject: + // Array (TextureState.h): unit 0 is the base. + dst.m_textureUnitBase = &ctx.GetTextureUnitObject(0); + break; + case F::GetTransformFeedbackCapturedVertices: + dst.m_transformFeedbackCapturedVertices = ctx.GetTransformFeedbackCapturedVertices(); + break; + case F::GetTransformFeedbackGeneration: + dst.m_transformFeedbackGeneration = ctx.GetTransformFeedbackGeneration(); + break; + case F::GetTransformFeedbackPausedPrimitiveCounter: + dst.m_transformFeedbackPausedPrimitiveCounter = ctx.GetTransformFeedbackPausedPrimitiveCounter(); + break; + case F::GetTransformFeedbackProgram: + dst.m_transformFeedbackProgram = ctx.GetTransformFeedbackProgram(); + break; + case F::GetViewport: + dst.m_viewport = ctx.GetViewport(); + break; + case F::GetViewportIndexed: + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + dst.m_viewportIndexed[i] = ctx.GetViewportIndexed(i); + } + break; + case F::IsCapabilityEnabled: + // Every capability, FramebufferSrgb included: it copies today's constant false + // (MEASUREMENTS.md), so no value changes. + for (SizeT i = 0; i < PipeInputs::kCapabilityCount; ++i) { + dst.m_capability[i] = ctx.IsCapabilityEnabled(static_cast(i)); + } + break; + case F::IsCapabilityEnabledIndexed: + // The only two indexed capabilities GLContext keeps (RenderState). + for (Uint i = 0; i < kMGMaxDrawBuffers; ++i) { + dst.m_capabilityIndexed.Blend[i] = ctx.IsCapabilityEnabledIndexed(CapabilityInput::Blend, i); + } + for (Uint i = 0; i < PipeInputs::kMaxViewports; ++i) { + dst.m_capabilityIndexed.ScissorTest[i] = + ctx.IsCapabilityEnabledIndexed(CapabilityInput::ScissorTest, i); + } + break; + case F::IsTransformFeedbackActive: + dst.m_transformFeedbackActive = ctx.IsTransformFeedbackActive(); + break; + case F::IsTransformFeedbackPaused: + dst.m_transformFeedbackPaused = ctx.IsTransformFeedbackPaused(); + break; + case F::GetBoundTransformFeedbackLifetimeId: + dst.m_boundTransformFeedbackLifetimeId = ctx.GetBoundTransformFeedbackLifetimeId(); + break; + // The seven forwarded fields: nothing to copy. + case F::GetBufferBindingPointCount: + case F::GetProgramObject: + case F::GetTextureObject: + case F::HasOpenTransformFeedbackSpan: + case F::InvalidateCompileEnv: + case F::ValidateProgramName: + case F::RecordError: + case F::kFieldCount: + break; + } + } + + static void SetIdentity(PipeInputs& inputs, GLContext* ctx) { + inputs.m_live = ctx != nullptr; + inputs.m_contextIdentity = ctx; + } + static void SetVerb(PipeInputs& inputs, MGPipeVerb verb) { inputs.m_currentVerb = verb; } +#if MOBILEGL_PIPE_POISON + static MGPipeFilledState& Filled(PipeInputs& inputs) { return inputs.m_filled; } +#endif + }; + + namespace { + GLContext* LiveContext() { return MG_State::pGLContext.get(); } + +#if MOBILEGL_BUILD_DISAGGREGATED + // ---- P5e (ra): the fill decision (CONTRACT-P5E §3.1) ----------------------------- + // + // Is this process a run-ahead CLIENT right now? Three questions, cheapest first, and + // the last one is the latch ClientSession took at its first caps adoption - so this is + // one pointer test and one bool load on the verb path once the first two are constant. + Bool ClientRunsAhead() { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return false; + if (MG_Remote::Client::RunsAsTheServerRole()) return false; + const MG_Remote::Client::ClientSession* session = MG_Remote::Client::ClientSession::Active(); + return session != nullptr && session->RunAheadArmed(); + } + + // THE WIRE OP A VERB BOUNDARY BECOMES, which is the join MGPipeVerbForWireOp draws in + // the other direction. Built by walking the op space once at compile time rather than + // written out, because a second hand-written table is a second thing to forget a row + // in - and the generator already refuses a verb-shaped op with no row. + constexpr MGPWireOp WireOpForVerb(MGPipeVerb verb) { + for (SizeT i = 0; i < static_cast(MGPWireOp::kOpCount); ++i) { + const auto op = static_cast(i); + if (MGPipeVerbForWireOp(op) == verb) return op; + } + return MGPWireOp::kOpCount; + } + + // CONTRACT-P5E §2.1's predicate, ASKED AT THE VALIDATE POINT - which is before the + // record exists, so it is asked of the verb and the live context rather than of the + // payload. The two halves must agree with MGPipeBarriered(op, payload, applierState), + // which is what the server computes, so each clause is the same clause: + // + // 1. the static WaitClass column - the same generated table, same op; + // 2. kCtxVerb inside an open XFB span - ctx.IsTransformFeedbackActive() here, + // the applied MGPContextValues mirror there, and set_context_values precedes the + // verb on the ring, so the two read the same value (§2.1); + // 3. a draw carrying kDrawClientArrays - NOT asked here, because under run-ahead + // such a draw never reaches a record at all: vi refuses it on this very thread in + // EmitDrawRecord's array arm (§5.1, ruling 2). Asking it here would be a second + // spelling of vi's "does any enabled attribute lack a buffer" walk, and two + // spellings of an escalation is precisely what ruling 3 replaced. + // + // AN OP WITH NO VERB ROW ANSWERS BARRIERED. A verb the join does not know is one this + // file cannot reason about, and the safe answer - fill it, wait for it - is also the + // pre-P5e answer. + // `ctx` MAY BE NULL, and that is not a convenience: the validate point asks this + // question BEFORE it has decided whether there is anything to fill, so that the answer + // is about the RECORD and not about the state of the block. A null context has no open + // transform-feedback span, so clause 2 is false for it - and the verb is a no-op below + // either way. + Bool ClientVerbIsBarriered(MGPipeVerb verb, GLContext* ctx) { + MGPWireOp op = WireOpForVerb(verb); + // ---- P5e (ra2): THE JOIN IS MANY-TO-ONE ON THE DRAW FAMILY, SO THE INVERSE IS NOT + // A FUNCTION, AND THE DEFAULT BELOW WAS ANSWERING FOR NINETEEN VERBS ----------------- + // + // MGP_VERB_OP_LIST carries ONE row for the whole draw family - `DrawVbo -> + // DrawArrays` - because that is the direction the SERVER needs: a draw_vbo record + // stamps a verb boundary and DrawArrays is the name it prints. Inverting it + // verb-first therefore answers kOpCount for DrawElements, DrawElementsBaseVertex, + // DrawElementsIndirect, MultiDrawElementsBaseVertex and every other indexed / + // instanced / multi / indirect verb - all of which emit exactly that same draw_vbo. + // + // The conservative default below reads "a verb the join does not know answers + // BARRIERED: fill it, wait for it". THE FILL HAPPENS AND THE WAIT DOES NOT. The wait + // is not decided here - it is decided per RECORD, by MGPipeBarriered(op, payload, + // st) at the publish (§2.1) - and the record is a draw_vbo, whose wait class is + // kWaitNone. So every indexed draw filled gPipeInputs while telling + // RefusePipeInputsTouchWhileApplierOwnsIt, through isBarrieredFill, that this thread + // was about to park behind it, and then ran on without parking. The apply thread was + // measured inside a record at that instant, and the abort it produced named the + // CLIENT's verb - a verb the applier cannot stamp, which is what identifies the + // writer (report §2). + // + // So the family's one row is applied to the family. Every other unknown verb keeps + // the conservative answer, which is now honest rather than hoped for: the fill site + // establishes quiescence before it writes (MGPipeValidateForVerb below), so a + // barriered fill no longer rests on a park that may never come. + static_assert(MGPipeVerbForWireOp(MGPWireOp::DrawVbo) == MGPipeVerb::DrawArrays, + "the draw family's representative row moved; the fallback below names " + "draw_vbo because that is the record every kDraw verb emits"); + // P5e (ra2): THE CLIENT'S HALF OF ESCALATION (iii) WAS HERE AND WENT WITH IT + // (ID-133, withdrawn by ID-136). It named MultiDrawArrays / MultiDrawElements / + // MultiDrawElementsBaseVertex so the client would FILL for the records the server + // was escalating - a fill the server then read as a barriered pull. Retiring that + // pull outright (MultiDraw.cpp's BoundDrawIndirectBufferId takes a handle arm) left + // nothing for the fill to serve, so a plain multi-draw is an ordinary kDraw verb + // again and takes the draw_vbo answer below. The pair is the phase's own lesson + // written twice: a wait added to make a lane green is paid by an arm, and here the + // arm was the default tier the phone ships. + if (op == MGPWireOp::kOpCount && + kMGPipeVerbClass[static_cast(verb)] == MGPipeVerbClass::kDraw) { + op = MGPWireOp::DrawVbo; + } + if (op == MGPWireOp::kOpCount) return true; + if (MGPipeWaitClassFor(op) != kWaitNone) return true; + if (MGPipeCallClassFor(op) == kCtxVerb && ctx != nullptr && + ctx->IsTransformFeedbackActive()) { + return true; + } + return false; + } + + // ---- P5e (ra2), CONTRACT-P5E §3.5 AMENDED: A BARRIERED FILL MAKES ITSELF QUIESCENT ---- + // + // §3.5 exempted the residual fill of a BARRIERED record from the single-writer rule on + // the ground that "this thread is about to park behind it". That is a claim about the + // FUTURE, and the write is in the PRESENT: the order at the validate point is fill, + // then emit, then park, and between the fill and the park the apply thread is still + // draining the UNBARRIERED records the client ran ahead of. The claim was therefore + // never an argument about this instant, and the guard that took it - the + // `if (isBarrieredFill) return;` arm of RefusePipeInputsTouchWhileApplierOwnsIt - could + // not fire however wrong the fill was. + // + // This makes the claim TRUE instead of asserting it. §2.5's forced wait is exactly + // "publish nothing, wait for the applier to reach LastPublishedSeq, drain the reverse + // channel", which is the definition of the window the fill needs, and it already exists + // for glFinish and for BackendObject_Remote's forwarders. + // + // IT IS CALLED AT EVERY GL-THREAD WRITE INTO THE BLOCK, not once per verb, because the + // validate point writes the block in TWO phases that straddle record publication: the + // serial bump / stamp withdrawal / verb rename run BEFORE the emitters, and the 63-field + // walk of step 4 runs AFTER them - and the records the emitters published are records + // whose apply READS the block. One wait cannot cover both halves. + // + // THE ARM IS STATED, NOT INFERRED (ID-81): WaitForApplyToCatchUp's own first test is + // `m_runAheadArmed && m_started`, and m_runAheadArmed is the latch of + // `Transport != Monolith && Ipc.RunAhead && kMGPipeP5eRunAheadReady && the caps bit`. So + // on the monolith arm, on the pull build and under MOBILEGL_IPC_RUN_AHEAD=0 this is a + // call that returns, and the lockstep client's behaviour is unchanged byte for byte + // (G1) - under lockstep appliedSeq is already at LastPublishedSeq by construction. + void QuiesceApplierBeforeFill(const char* surface) { + MG_Remote::Client::ClientSession* session = MG_Remote::Client::ClientSession::Active(); + if (session == nullptr) return; // no session: this process has no applier to outrun + session->WaitForApplyToCatchUp(surface); + } + + // Whether the LAST fill actually happened, i.e. whether the rows in the block describe + // the verb in flight. Read by MGPipeNoteFrontendMutation, which refreshes one field of + // that fill: with no fill behind it there is nothing to refresh and the write would be + // the role violation §3.5 names. + Bool g_lastFillWasBarriered = true; +#endif + + template + const SharedPtr& NullShared() { + static const SharedPtr null; + return null; + } + + [[noreturn]] void BadKnob(const char* knob, const char* value, const char* why) { + MGLOG_F("MGPipe: Fatal{PipeVerifyBadKnob, \"%s=%s\": %s}", knob, value, why); + std::abort(); + } + + // ---- MOBILEGL_PIPE_POISON_OMIT (negative control B, P1 brief D6) ---- + // The filler skips the STAMP (never the value) of one (verb, field) pair: an omission + // indistinguishable from a forgotten FillPoints.def row, so that verb's read of the + // field is Fatal{UnmigratedPipeInput, "Field@Verb"} and no other verb is affected. + struct PoisonOmission { + Bool Armed = false; + MGPipeVerb Verb = MGPipeVerb::kVerbCount; + MGPipeInputField Field = MGPipeInputField::kFieldCount; + }; + PoisonOmission g_omission; + Bool g_omissionKnobParsed = false; + String g_omissionKnobValue; // the value the last parse saw + + // Parsed on the first fill and again only when the value changes. A lane loads + // Features once, before any fill, so that is one parse per process there; a forked + // test child that sets Features after its parent already filled gets its own parse, + // which is what puts the parser and its Fatal{PipeVerifyBadKnob} under a unit test. + // An empty value never clears an omission a test armed through MGPipeSetPoisonOmission. + void ParsePoisonOmissionKnob() { + const String& knob = MG_Config::Features.PipePoisonOmit; + // THE UNSET KNOB IS TWO LENGTH LOADS AND A BRANCH, inline, and that is the whole + // change (P5d r3, package C): this runs at every verb - 852 draws a frame on the + // profiled workload - and `knob == g_omissionKnobValue` is an out-of-line String + // compare even when both sides are empty, which is every shipping configuration. + // Sizes first, contents only when a non-empty value is involved; the re-parse + // condition is unchanged (the value differing from what the last parse latched). + if (g_omissionKnobParsed && knob.size() == g_omissionKnobValue.size() && + (knob.empty() || knob == g_omissionKnobValue)) { + return; + } + g_omissionKnobParsed = true; + g_omissionKnobValue = knob; + if (knob.empty()) return; + const auto colon = knob.find(':'); + if (colon == String::npos || colon == 0 || colon + 1 >= knob.size()) { + BadKnob("MOBILEGL_PIPE_POISON_OMIT", knob.c_str(), "expected :"); + } + const String verbName = knob.substr(0, colon); + const String fieldName = knob.substr(colon + 1); + const auto verb = MGPipeFindVerb(verbName.c_str()); + if (!verb) BadKnob("MOBILEGL_PIPE_POISON_OMIT", knob.c_str(), "no such verb in kMGPipeVerbNames"); + const auto field = MGPipeFindInputField(fieldName.c_str()); + if (!field) BadKnob("MOBILEGL_PIPE_POISON_OMIT", knob.c_str(), "no such field in kMGPipeInputFieldNames"); + MGPipeSetPoisonOmission(verbName.c_str(), fieldName.c_str()); + } + + [[maybe_unused]] Bool IsOmitted(MGPipeVerb verb, MGPipeInputField field) { + return g_omission.Armed && g_omission.Verb == verb && g_omission.Field == field; + } + +#if MOBILEGL_PIPE_VERIFY + // ---- the MOBILEGL_PIPE_VERIFY comparator (P1 brief D8) ---- + // Two mechanisms, both active only when Features.PipeVerify is set: the ENTRY compare + // once per verb (the pushed block against a second snapshot of the live context, + // taken at the same instant - tautological until P2 gives the first arm a real + // filler, and kept falsifiable by MOBILEGL_PIPE_VERIFY_CORRUPT), and the + // COMPARE-AT-READ in every accessor (the stored value against a fresh read of the + // live context at the moment the backend reads it - the arm that is real in P1: it + // catches a value that changed between the verb boundary and the read). + // LEAK-AT-EXIT STORAGE, for gPipeInputs' reason (MG_Backend/MGPipe/PipeInputs.h): a + // PipeInputs holds SharedPtrs to frontend objects in its O class, and these two are + // filled from the live context, so either can hold the LAST reference to a + // VertexArrayObject or a ProgramObject. Destroying them from __run_exit_handlers + // would run ~VertexArrayObject / ~BufferObject at exit, into a pipe and a backend + // that are already being torn down. References so the ~50 uses below need no edit. + PipeInputs& g_snapshot = *new PipeInputs(); // the second arm + PipeInputs& g_readScratch = *new PipeInputs(); // where the compare-at-read re-read lands + + // The read hook arms at the first fill (ArmVerify below), so it cannot see a read + // made before that. That window is covered by the poison instead: MGP_INPUT_CHECK + // precedes MGP_INPUT_VERIFY_READ in every accessor and a stamp of 0 is never fresh, + // so such a read is Fatal{UnmigratedPipeInput, "@"} before the hook + // could matter - which holds only while a verify build always carries the poison. + static_assert(MOBILEGL_PIPE_POISON, "the compare-at-read hook relies on the poison for reads before the first fill"); + + struct VerifyState { + Bool Parsed = false; + Bool Enabled = false; + Bool Fatal = true; + Bool InHook = false; // a re-read that re-enters an accessor is not re-verified + Optional Corrupt; + String CorruptKnob; // the MOBILEGL_PIPE_VERIFY_CORRUPT value the last arm saw + std::atomic Divergences{0}; + ~VerifyState() { + const Uint64 count = Divergences.load(std::memory_order_relaxed); + if (count != 0) { + MGLOG_E("MGPipe: verify summary - %llu divergence(s) survived MOBILEGL_PIPE_VERIFY_FATAL=0", + static_cast(count)); + } + } + }; + VerifyState g_verify; + + // Armed on the first fill and re-armed when any of the three verify knobs' + // Features value (PipeVerify, PipeVerifyFatal, PipeVerifyCorrupt) differs from what + // the last arm latched (the same reason as ParsePoisonOmissionKnob: one arm per lane + // process, a fresh arm for a forked test child that turns a knob after its parent + // filled). Cost: two Bool compares and one String compare per fill, verify builds only. + void ArmVerify() { + const auto& features = MG_Config::Features; + if (g_verify.Parsed && g_verify.Enabled == features.PipeVerify && g_verify.Fatal == features.PipeVerifyFatal && + g_verify.CorruptKnob == features.PipeVerifyCorrupt) { + return; + } + g_verify.Parsed = true; + g_verify.Enabled = features.PipeVerify; + g_verify.Fatal = features.PipeVerifyFatal; + g_verify.CorruptKnob = features.PipeVerifyCorrupt; + g_verify.Corrupt = Optional{}; + if (!g_verify.Enabled) return; + const String& corrupt = g_verify.CorruptKnob; + if (!corrupt.empty()) { + const auto field = MGPipeFindInputField(corrupt.c_str()); + if (!field) { + BadKnob("MOBILEGL_PIPE_VERIFY_CORRUPT", corrupt.c_str(), "no such field in kMGPipeInputFieldNames"); + } + g_verify.Corrupt = field; + } + // The lanes grep for this line: a verify run whose log lacks it never armed. + MGLOG_I("MGPipe: verify armed - %u fields, %u verbs, fatal=%d", static_cast(kMGPipeInputFieldCount), + static_cast(kMGPipeVerbCount), g_verify.Fatal ? 1 : 0); + if (g_verify.Corrupt) { + MGLOG_I("MGPipe: verify corruption armed - %s", kMGPipeInputFieldNames[static_cast(*g_verify.Corrupt)]); + } + } + + void ReportDivergence(MGPipeInputField field, const char* where) { + const Uint64 serial = MGPipeFillAccess::Filled(gPipeInputs).CurrentVerbSerial; + MGLOG_F("MGPipe: Fatal{PipeVerifyDiffer, \"%s@%s\", verb=%llu, where=%s}", + kMGPipeInputFieldNames[static_cast(field)], MGPipeVerbName(gPipeInputs.CurrentVerb()), + static_cast(serial), where); + if (g_verify.Fatal) std::abort(); + g_verify.Divergences.fetch_add(1, std::memory_order_relaxed); + } + + void EntryCompare(PipeInputs& inputs, const MGPipeFieldMask& mask) { + if (!g_verify.Enabled) return; + SnapshotFromGLContext(g_snapshot, mask); + // Negative control A: perturb the SNAPSHOT arm, so a green run goes red naming the + // field. A field outside this verb's mask is not compared and stays untouched. + if (g_verify.Corrupt && MGPipeFieldMaskHas(mask, *g_verify.Corrupt)) { + MGPipeApplyVerifyCorruption(g_snapshot, *g_verify.Corrupt); + } + MGPipeInputField differing = MGPipeInputField::kFieldCount; + if (!MGPipeVerifyInputs(inputs, g_snapshot, mask, &differing)) ReportDivergence(differing, "entry"); + } +#endif // MOBILEGL_PIPE_VERIFY + } // namespace + +#if MOBILEGL_PIPE_VERIFY + void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask) { + auto* ctx = LiveContext(); + MGPipeFillAccess::SetIdentity(snapshot, ctx); + MGPipeFillAccess::SetVerb(snapshot, gPipeInputs.CurrentVerb()); + if (ctx == nullptr) return; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field) || kMGPipeInputFieldSticky[i]) continue; + MGPipeFillAccess::CopyField(snapshot, *ctx, field); + } + } + + void MGPipeVerifyReadHook(const PipeInputs& self, MGPipeInputField field, Uint index0, Uint index1) { + if (&self != &gPipeInputs || !g_verify.Enabled || g_verify.InHook) return; + const auto index = static_cast(field); + if (kMGPipeInputFieldSticky[index]) return; + auto* ctx = LiveContext(); + if (ctx == nullptr) return; + // The whole field is re-read and compared - a superset of "the same indices", so a + // divergence in an index the backend did not ask for is still a divergence between + // the boundary value and the live value. The indices only decorate the report. The + // cost is per backend read (GetRenderStateParameters re-copies and compares the whole + // struct; GetProgramForDraw re-joins the pending link), inside the verify budget and + // to be kept in mind when reading the verify lane's wall time. + // InHook: the re-read calls the same GLContext accessor the filler calls, and + // GetProgramForDraw's join can re-enter a backend and with it another gPipeInputs + // accessor; that inner read is a plain load rather than a second hook, so the hook + // never recurses (and never reports the inner read against a half-copied scratch). + g_verify.InHook = true; + MGPipeFillAccess::CopyField(g_readScratch, *ctx, field); + const Bool equal = MGPipeInputsFieldEqual(field, self, g_readScratch); + g_verify.InHook = false; + if (equal) return; + MGLOG_E("MGPipe: verify read of %s (index %u, %u) differs from the live context", kMGPipeInputFieldNames[index], + index0, index1); + ReportDivergence(field, "read"); + } +#endif // MOBILEGL_PIPE_VERIFY + + // ---- push on mutation (P1 lane finding F2) ---- + // A backend that writes a frontend object inside its own verb moves a value the verb + // boundary already copied: Magma's ResolveSamplerDescriptor synthesises a fallback + // texture for an unbound sampler and its AllocateStorage/SetInternalFormat bump the + // context's sampling-resolution generation, so every read of that field after the + // fallback differs from the live context (the two SampledSetStaleness / six + // UnboundImageDescriptor entries the verify lane aborted on). The frontend mutator + // spells MGP_NOTE_MUTATION(Field) at the point of the move and lands here. + // + // Only the value is refreshed. The stamp is deliberately left alone: a field whose stamp + // this verb withheld (negative control B) must stay stale, and a field the verb never + // filled must stay Fatal{UnmigratedPipeInput} on the next read rather than be healed by + // an unrelated frontend write. + void MGPipeNoteFrontendMutation(MGPipeInputField field) { + PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (ra, CONTRACT-P5E §3.1): THIS REFRESHES ONE FIELD OF THE LAST FILL, so with no + // fill behind it there is nothing to refresh. Under run-ahead the last verb may have + // been unbarriered, in which case the block describes an older verb the server is no + // longer being asked about and a write here would be a GL-thread touch of server-role + // memory. It returns instead - the same answer, one line earlier, as the class-mask + // test below gives for a field the verb never pushed. + if (!g_lastFillWasBarriered) return; + // P5c (gt, layer 2): the single-field refresh is a client write into gPipeInputs too - + // same gate as the fill, and it makes the same claim the fill made: the rows it is + // touching belong to a record this thread is parked behind (or will park behind). + // + // P5e (ra2): SO IT TAKES THE SAME WAIT. This one runs at a frontend mutation point, not + // at a verb, so "will park behind" is even weaker here than it is at the fill - the + // mutation can land anywhere between two verbs, with the whole run-ahead backlog in + // flight. The wait is a no-op unless run-ahead is armed, and at a mutation point after a + // barriered verb the applier is usually already caught up, so this is a watermark test + // rather than a park in the common case. + QuiesceApplierBeforeFill("MGPipeNoteFrontendMutation"); + MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt( + "MGPipeNoteFrontendMutation", /*isBarrieredFill=*/true); +#endif + auto* ctx = LiveContext(); + if (ctx == nullptr) return; + const auto verb = inputs.CurrentVerb(); + if (verb == MGPipeVerb::kVerbCount) return; // nothing has filled the block yet + const auto index = static_cast(field); + if (kMGPipeInputFieldSticky[index]) return; // forwarded: no storage to refresh + const MGPipeFieldMask& mask = + kMGPipeClassFieldMask[static_cast(kMGPipeVerbClass[static_cast(verb)])]; + if (!MGPipeFieldMaskHas(mask, field)) return; // this verb never pushed it + MGPipeFillAccess::CopyField(inputs, *ctx, field); + } + + // ---- the aggregate generations (P2 brief D4) ---- + // MGP_NOTE_AGGREGATE lands here. The bump points are on OBJECTS, which have no + // back-pointer to the state container that owns them, so the note finds the live + // context - the same shape, and for the same reason, as MGPipeNoteFrontendMutation + // above. No verb has to be in flight and no field is stamped: an aggregate generation + // is not a PipeInputs field, it is what the tracker's shutter compares against. + void MGPipeNoteAggregate(MGPipeAggregate aggregate) { + auto* ctx = LiveContext(); + if (ctx == nullptr) return; + switch (aggregate) { + case MGPipeAggregate::VaoAttribute: + ctx->NoteVaoAttributeChanged(); + break; + case MGPipeAggregate::FramebufferAttachment: + ctx->NoteFramebufferAttachmentChanged(); + break; + case MGPipeAggregate::TextureContent: + ctx->NoteTextureContentChanged(); + break; + case MGPipeAggregate::TextureParams: + ctx->NoteTextureParamsChanged(); + break; + case MGPipeAggregate::BufferChange: + ctx->NoteBufferChanged(); + break; + case MGPipeAggregate::VertexAttribDefault: + ctx->NoteVertexAttribDefaultChanged(); + break; + case MGPipeAggregate::Count: + break; + } + } + + // ================================================================================ + // P3a: the resource family's emission (brief D-A, D-B, D-C, D-D) + // ================================================================================ + // + // Declared in MG_Pipe/PipeMutation.h and defined here for the layering reason that + // header states: the emission sites are BufferObject's dispatchers, which are MG_State's, + // and MG_State may see a declaration but never MG_Impl/Pipe/ResourceTracker.h. + // + // Every one of these is called from a site that has ALREADY asked + // MGPipeResourceSubsystemEnabled(), except the mint and the destroy - the handle is + // client state and its lifetime is the frontend object's, not the subsystem's. + namespace { + using MG_State::GLState::BufferObject; + + MGPHandleOnly BufferHandleOnly(MGPipeHandle handle) { + MGPHandleOnly only{}; + only.Handle = handle; + only.Kind = static_cast(MGPipeKind::Buffer); + return only; + } + + // THE CONTENT PATHS LOOK THE HANDLE UP, THEY DO NOT MINT IT. Acquire mutates the + // process-global slot allocator (a map insert on a miss, a free-list pop) and then + // resizes the tracker's inverse vector; D-A2 preserves the off-thread/stale-queue arm + // of Ops_SubData, so BufferObject::NotifySubData is reachable off the render thread, + // and two threads inside Acquire - or one there while ~BufferObject is in Free - is a + // torn free list and a dangling span. The mint happens ONCE, on the GL thread, in the + // BufferObject constructor (MGPipeMintResourceHandle), so on every content path the + // handle already exists and a pure lookup is not merely safe but strictly correct. + // + // A null answer therefore means a buffer whose constructor did not mint - which + // cannot happen in a push build - or a lifetime id already freed. Either way the call + // is dropped, so it is said out loud rather than passing kMGPipeNullHandle to the + // applier, which would count it as a refusal with no way back to the cause. + MGPipeHandle ContentHandleFor(const BufferObject& buffer, const char* call) { + const MGPipeHandle handle = MGPipeResourceTrackerInstance().Find(buffer); + if (MGPipeHandleIsNull(handle)) { + MGLOG_E_ONCE("MGPipe: %s on buffer %u has no resource handle - the call is dropped; a " + "push build mints one in the BufferObject constructor, so this is a lifetime " + "id that was already freed", + call, buffer.GetExternalIndex()); + } + return handle; + } + } // namespace + + // R-8 (c1). THE SECOND CONJUNCT MOVES UNDER SPLIT, AND ONLY UNDER SPLIT. + // + // `MGPipeGetResourceOps() != nullptr` asks "has a backend registered the consumer". That + // table is the SERVER's registration and it is a PROCESS-WIDE global (PipeApply.cpp:402): + // under inproc a client reading it answers correctly BY ACCIDENT, and under spawn the + // client process has no backend at all, so the read answers null and five record families + // stop emitting - silently, while the emitters go on clearing their per-level dirty flags + // on the acceptance they never asked for. That is ID-39's 66 lost DirectVulkan uploads with + // a wire in between. The client asks the caps mirror instead, which carries the answer the + // SERVER gave at the handshake (CallMask bits 32..47). + // + // s1 made the server end Fatal when nobody sets the mask; this is the client end. + Bool MGPipeResourceSubsystemEnabled() { + if ((MG_Config::Features.PipePush & kMGPipeSubsystemResources) == 0) return false; +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return MG_Remote::Client::CapsMirrorInstance().ServerConsumes(kMGPipeSubsystemResources); + } +#endif + return MGPipeGetResourceOps() != nullptr; + } + + Bool MGPipeResourceOpsHaveSubDataResident() { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // CONTRACT-P5.md §7's THIRD NAMED CAPABILITY PROBE. `ops->SubDataResident != nullptr` + // is not a safety check - it decides whether the resident-upload path EXISTS - and + // under split there is no op table here to probe. kCapResidentSubData is the bit the + // server publishes for exactly this question. + return MG_Remote::Client::CapsMirrorInstance().HasCap(kCapResidentSubData); + } +#endif + const MGPipeResourceOps* ops = MGPipeGetResourceOps(); + return ops != nullptr && ops->SubDataResident != nullptr; + } + + void MGPipeMintResourceHandle(BufferObject& buffer) { + // UNCONDITIONAL in a push build, deliberately: set_vertex_buffers names a buffer by + // handle whether or not the resource family is switched on, so gating the mint on + // the resource subsystem bit would make the vertex-input subsystem emit null handles + // in exactly the A/B arm that exists to isolate the two. It costs one free-list pop + // and one map insert per buffer object and emits nothing. + // + // The client resource callbacks are MONOLITH-ONLY (CONTRACT-P5C §4.1): with an + // active transport the server session installs its producer callbacks at Accept and + // the client's consumers are invoked by name from DrainEventRing, so installing + // them here would be the double installation the check now aborts on. +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) { + MGPipeInstallClientResourceCallbacks(); + } +#else + MGPipeInstallClientResourceCallbacks(); +#endif + MGPipeResourceTrackerInstance().Acquire(buffer); + } + + void MGPipeEmitResourceCreate(BufferObject& buffer) { + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + const MGPipeHandle handle = tracker.Acquire(buffer); + Uint16 bindMask = tracker.BindMask(handle); + if (auto* ctx = LiveContext()) bindMask = tracker.RefreshBindMask(*ctx, buffer, handle); + // storageDefined = false: the constructor has no store yet, storage is defined lazily + // by the first respecify, and the backend's ensure path already tolerates a resource + // that has none. + const MGPResourceDesc desc = MGPipeBuildResourceDesc(buffer, handle, bindMask, false); + tracker.NoteDesc(desc, true); + // LATCHED, so the destroy is gated on whether this create actually went out rather + // than on whether a table is still registered when the object dies (D-L, m12). + tracker.NotePublished(handle); + MGPipeRouteResourceCreate(desc); + } + + void MGPipeEmitResourceRespecify(BufferObject& buffer) { + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + const MGPipeHandle handle = tracker.Acquire(buffer); + Uint16 bindMask = tracker.BindMask(handle); + if (auto* ctx = LiveContext()) bindMask = tracker.RefreshBindMask(*ctx, buffer, handle); + // M-1: THE CREATE FIRST, IF THIS HANDLE NEVER PUBLISHED ONE - which makes the + // create/destroy latch self-healing in both directions instead of only one. + // + // The constructor's create is gated on MGPipeResourceSubsystemEnabled(), which is bit 7 + // AND "a backend registered MGPipeResourceOps"; the CONSUMER's gate is bit 7 alone. The + // two disagree across a register/unregister boundary, and there is a real window: + // UnregisterBufferBackendOps nulls the table from OnBackendContextDestroyed + // (DestroyEGLContext) and the re-register happens at the next MakeCurrent, while D-A2 + // deliberately keeps NotifySubData reachable off the render thread. A buffer born in + // that window latched Published = false, so the applier had no record for it and every + // later respecify was REFUSED - after which EnsureBufferResourceForHandle read + // ResourceRecordOf == nullptr, took size 0, returned a twin with no store and drew + // through id 0, with no diagnostic anywhere. The legacy arm recovers from the same + // window by twinning lazily off the frontend object and full-uploading from the shadow; + // this is the handle arm's equivalent, and it costs one bool compare per respecify. + // + // A create rather than a respecify because that is what the record's absence means: the + // applier starts the record over on a create (it does not edit one), so this cannot + // resurrect a field from a recycled slot, and the respecify below then defines the + // storage exactly as it would have. + if (!tracker.WasPublished(handle)) { + const MGPResourceDesc createDesc = + MGPipeBuildResourceDesc(buffer, handle, bindMask, /*storageDefined=*/false); + tracker.NoteDesc(createDesc, true); + tracker.NotePublished(handle); + MGPipeRouteResourceCreate(createDesc); + } + const MGPResourceDesc desc = MGPipeBuildResourceDesc(buffer, handle, bindMask, true); + tracker.NoteDesc(desc, false); + // initialBytes is the client's own shadow base - zero copy, and null is a real answer + // for the orphaning idiom (a NULL-data respecify leaves the store undefined and the + // backend must not upload the stale bytes). + const void* initialBytes = desc.HasDefinedContent != 0 ? buffer.MappedData() : nullptr; + // kNeedsAck rides on the CALL and MGPipeResourceRespecifyNeedsAck(desc) decides per + // record: only an immutable store (a glBufferStorage*) is a real synchronous + // allocation and only it is allowed one. In monolith the acknowledgement is + // ((void)0), because the applier is one function call away and has already run by + // the time this returns; the transport wires the doorbell to that same predicate. +#if MOBILEGL_BUILD_DISAGGREGATED + // R-13.3's MISSING PRODUCER, and it is the reason the first joint inproc run died. + // CONTRACT-P5 §2 row 19 rules that `initialBytes` is ALWAYS nullptr under split and + // that "initial content arrives as ResourceSubData records immediately after this + // one" - but nothing emitted those records, so the split arm's refusal + // (Fatal{UncarriedInitialBytes}) fired on the first glBufferData with data, which is + // the first thing every scenario does. + // + // IT IS THE CONTRACT'S OWN PRESCRIBED ROUTE, not a new one: "the chosen route reuses a + // path that is already chunked (MGPipeForEachSubDataRecordRange) and already + // acceptance-gated; it costs one extra record". So the respecify defines the storage + // and the walk below ships the bytes, through the same emitter every later + // glBufferSubData uses - which also means the HasLiveHostWrites bit and the + // acceptance latch are computed in exactly one place instead of two. + // + // SPLIT-ONLY, and that is load-bearing for G2: under monolith the applier reads + // `initialBytes` directly and a second upload would be a real behaviour change in the + // arm the split arm is measured against. + // + // ROLE-AWARE (M5). Under inproc the apply thread reaches this very emitter when the + // server's own backend respecifies a buffer (c1-v2 §4 R-17.3: that is where + // Fatal{BarrierTimeout, "ResourceRespecify"} from mgl-srv-apply came from). On that + // thread this branch would emit CLIENT wire records - which the server role does not + // produce, so ClientWireRecordsEmitted() would not move and the self-check below would + // abort the SERVER by name; it would also run the respecify(nullptr)+follow-up shape, + // giving the server role a path monolith does not have. So the server role takes the + // ELSE below, exactly as monolith does. RunsAsTheServerRole() is v1's + // ServerLoop::OnApplyThread(), false on the GL thread that owns this fill. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Remote::Client::RunsAsTheServerRole() && initialBytes != nullptr) { + MGPipeRouteResourceRespecify(desc, nullptr); + // COUNTED, NOT ASSUMED, and this is the only thing that can gate the follow-up at + // all. Dropping the walk below leaves a respecify that went out with nullptr and + // bytes that nothing carried - and no P5 scenario's PICTURE changes, because every + // one of them re-uploads its vertices through the ordinary dirty path afterwards. + // So the statement "the content followed" is made HERE, against the client's own + // record ordinal, rather than left to a lane that cannot see it. Remove the call + // below and this aborts by name on the first glBufferData that carries data. + const Uint64 before = MG_Remote::Client::ClientWireRecordsEmitted(); + MGPipeEmitResourceSubData(buffer, 0, static_cast(buffer.GetSize())); + if (MG_Remote::Client::ClientWireRecordsEmitted() == before) { + MGLOG_F("MGPipe: Fatal{InitialBytesNotCarried, \"resource_respecify\"} - the " + "respecify crossed with initialBytes = nullptr (R-13.3) and the " + "resource_subdata records that were supposed to follow it emitted " + "NOTHING, so %llu bytes of initial content exist on no side of the wire", + static_cast(buffer.GetSize())); + std::abort(); + } + return; + } +#endif + MGPipeRouteResourceRespecify(desc, initialBytes); + } + + void MGPipeEmitResourceSubData(BufferObject& buffer, SizeT offset, SizeT size) { + const MGPipeHandle handle = ContentHandleFor(buffer, "resource_subdata"); + if (MGPipeHandleIsNull(handle)) return; + const Uint8* base = buffer.MappedData(); + const Bool encodable = + MGPipeForEachSubDataRecordRange(offset, size, [&](Uint64 at, Uint64 length) { + MGPSubData record{}; + // The pre-pass inside the walk proved every piece encodable before the first + // one was emitted, so this cannot be false - but a zeroed record (null + // handle, size 0) is not the answer if that pre-pass is ever relaxed. + if (!MGPipeBuildSubDataRecord(handle, at, length, record, /*verbatimShadow=*/true)) return; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5 (b1): the live-host-writes bit rides the content record, because + // "someone may be writing these bytes without telling you" is a fact about the + // CONTENT and not about the storage. It is set from the object's PUBLISHED + // value rather than from a live IsMapped() read so that the record and the + // edge that announced it can never disagree. MGPipeBuildSubDataRecord does not + // take the object, which is why it is set here and not in the builder. + record.HasLiveHostWrites = buffer.HasLiveHostWritesForWire() ? 1 : 0; +#endif + // `length` is this chunk's byte count, which the record also declares + // (MGPipeBuildSubDataRecord writes it into the destination range) - passed + // rather than re-read so the staged run and the record's own claim come from + // one number. + MGPipeRouteResourceSubData(record, base + at, length); + }); + if (!encodable) { + MGLOG_E_ONCE("MGPipe: resource_subdata range [%llu, +%llu) on buffer %u cannot be encoded - " + "one record's destination box caps the offset at 2^31-1", + static_cast(offset), static_cast(size), + buffer.GetExternalIndex()); + } + } + + void MGPipeEmitBufferSubDataResident(BufferObject& buffer, SizeT offset, const void* bytes, SizeT size) { + const MGPipeHandle handle = ContentHandleFor(buffer, "buffer_subdata_resident"); + if (MGPipeHandleIsNull(handle)) return; + const auto* base = static_cast(bytes); + const Bool encodable = + MGPipeForEachSubDataRecordRange(offset, size, [&](Uint64 at, Uint64 length) { + MGPSubData record{}; + // NOT a verbatim level shadow: these bytes are the application's staging + // store, or the pattern FillSubData expanded locally, and neither is this + // client's untransformed shadow of the level. + if (!MGPipeBuildSubDataRecord(handle, at, length, record, /*verbatimShadow=*/false)) return; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5 (b1): THE SECOND CONTENT EMITTER, and it has to speak for the same reason + // the first does. ApplyBufferWrite ASSIGNS the bit - a content record emitted + // while nothing maps the buffer is how the state goes back to false - so a + // resident sub-data that stayed silent would write false over a live write + // map. `glBufferSubData` against a persistently mapped arena is legal and is + // the ordinary Flywheel/Create shape, so that is not a corner. + record.HasLiveHostWrites = buffer.HasLiveHostWritesForWire() ? 1 : 0; +#endif + // The application's STAGING store, valid for the duration of the call only. + MGPipeRouteBufferSubDataResident(record, base + (at - offset), length); + }); + if (!encodable) { + MGLOG_E_ONCE("MGPipe: buffer_subdata_resident range [%llu, +%llu) on buffer %u cannot be encoded", + static_cast(offset), static_cast(size), + buffer.GetExternalIndex()); + } + } + + void MGPipeEmitResourceFlushRange(BufferObject& buffer, SizeT offset, SizeT size, Uint32 accessFlags) { + const MGPipeHandle handle = ContentHandleFor(buffer, "resource_flush_range"); + if (MGPipeHandleIsNull(handle)) return; + MGPFlushRange record{}; + record.Res = handle; + record.Offset = offset; + record.Size = size; + // The application's REAL flags, not a normalised subset: the backend's kill-switch + // arm reads INVALIDATE_RANGE / INVALIDATE_BUFFER / UNSYNCHRONIZED per call to choose + // between a map+memcpy+unmap and an upload, so merging them here would change which. + record.AccessFlags = accessFlags; +#if MOBILEGL_BUILD_DISAGGREGATED + // R-13.2's MISSING PRODUCER, the twin of the respecify one above, and the cause of the + // seven PersistentCoherentMapScenario aborts on the first joint inproc run: + // Fatal{ProtocolCorruption} resource_flush_range {slot=1, gen=0, glName=1}: + // a non-empty flush carries no bytes (offset=0, size=120, storage=120 bytes) + // + // CONTRACT-P5 §2 row 20 rules that this record carries NO bytes under split - it is a + // {range, AccessFlags} control record, and a blobref here "would be a second, + // forgeable way to say the same thing" - and that "the bytes of [Offset, Offset+Size) + // arrive AHEAD of it as ResourceSubData records covering exactly that range". Nothing + // emitted those records, so v1's StagedShadowStore had nothing staged for the range + // the flush names, which is precisely the refusal ID-37 asked it to make rather than + // silently reading the bytes again. + // + // EXACTLY THAT RANGE, not the whole buffer: the flush's own [offset, size) is what + // the ladder rewrites, and staging more would be the coverage WIDENING ID-37 forbids. + // Split-only, for the respecify's G2 reason - and ROLE-AWARE for M5's reason, the twin of + // the respecify branch above: the apply thread flushing the server's own buffer emits no + // client wire records, so it takes the plain route below rather than this split follow-up. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Remote::Client::RunsAsTheServerRole() && size != 0) { + MGPipeEmitResourceSubData(buffer, offset, size); + } +#endif + MGPipeRouteResourceFlushRange(record, buffer.MappedData() + offset); + } + + void MGPipeEmitResourceReadback(BufferObject& buffer) { + // Whole-buffer by contract (BufferObject.h: the op pulls the backend's current + // contents for the WHOLE buffer into the shadow). The split arm's slicing lives in + // the CALLER (BufferObject::SyncGpuWrites): what "whole" costs is decided by the + // event ring's capacity, which this layer does not read. + MGPipeEmitResourceReadbackRange(buffer, 0, buffer.GetSize()); + } + + void MGPipeEmitResourceReadbackRange(BufferObject& buffer, SizeT offset, SizeT size) { + const MGPipeHandle handle = ContentHandleFor(buffer, "resource_readback"); + if (MGPipeHandleIsNull(handle)) return; + MGPReadback record{}; + record.Res = handle; + record.Offset = offset; + record.Size = size; + // The answer travels back through MGPipeClientOnBufferWriteback, and the server's + // epoch bump happens AFTER that writeback, never before. + MGPipeRouteResourceReadback(record); + } + + // NO UnmapPersistent PRODUCER IN P3a, AND THAT IS DELIBERATE. The catalogue has the call + // and wire implemented it, but D-J forbids new behaviour and there is nothing to convert: + // BufferBackendOps has seven hooks and none of them is an unmap, and + // PipeResource::ReleasePersistentMap() (BufferObject.cpp, from RedefineStorage) tells the + // backend nothing today - it learns from the Respecify that follows. Emitting + // unmap_persistent here would therefore be a new call to a backend that has never been + // told about a release, so the client emits none and the applier's refusal counter stays + // at 0 for it. The producer lands with the phase that gives the backend an unmap hook. + void* MGPipeEmitMapPersistent(BufferObject& buffer) { + const MGPipeHandle handle = ContentHandleFor(buffer, "map_persistent"); + if (MGPipeHandleIsNull(handle)) return nullptr; + MGPipeResourceTrackerInstance().NoteMapPersistent(); + // THE map-persistent-roundtrips SITE, and it counts every EMISSION - mint OR + // DECLINE - because every one of them needs an answer from the resource owner. A + // counter defined as "round trips actually taken" is 0 by construction in monolith + // and could never go red for the reason it exists; this one is the same number in + // both modes and is "one per storage definition" exactly as the design requires. + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::MapPersistentRoundtrips, 1); + } + return MGPipeRouteMapPersistent(BufferHandleOnly(handle), buffer.GetSize(), buffer.MappedData()); + } + + Bool MGPipeEmitResourceDestroyAndFree(BufferObject& buffer) { + MGPipeResourceTracker& tracker = MGPipeResourceTrackerInstance(); + const MGPipeHandle handle = tracker.Find(buffer); + if (MGPipeHandleIsNull(handle)) return false; + // THE LATCHED ANSWER, not the live one (m12): create and destroy are gated at two + // different moments, and a buffer constructed while a backend's table was registered + // and destroyed after UnregisterBufferBackendOps() would otherwise free its slot with + // the applier's record still Live and the backend's twin still attached to it - on a + // slot the allocator is about to hand out again. + const Bool published = tracker.WasPublished(handle); + if (published) { + tracker.NoteDestroy(); + MGPipeRouteResourceDestroy(BufferHandleOnly(handle)); + } + // THE ORDER IS FIXED (D-L): the applier clears the record and the backend drops its + // twin while the handle still resolves, and only then does the slot go back. Free + // erases the lifetimeId -> slot mapping, so a notice resolved twice finds nothing the + // second time - and the Gen bump happens on the NEXT handout of the slot, not here, + // so a double free cannot skip a generation. + tracker.Retire(handle); + MGPipeSlots().Free(MGPipeKind::Buffer, handle); + return published; + } + +#if MOBILEGL_BUILD_DISAGGREGATED + namespace { + struct MGPipeDeferredDestroy { + MGPipeKind kind; + Uint64 lifetimeId; + }; + // A plain mutex, not a lock-free queue: producers are destructors that lost the + // last-reference race (rare), the consumer is the GL thread's verb hook, and neither + // holds the lock past a vector push/swap. The count is the fast no-work check for + // the per-verb drain. + std::mutex g_deferredDestroyMutex; + Vector g_deferredDestroys; + std::atomic g_deferredDestroyCount{0}; + } // namespace + + Bool MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind kind, Uint64 lifetimeId) { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return false; + if (!MG_Remote::Client::RunsAsTheServerRole()) return false; + // P5e (ra), CONTRACT-P5E §2.7 / ruling 13. The queue stays - a BARRIERED record's apply + // may still pin a frontend object (its fill's O-class rows, XFB's targets), so the + // apply thread can still be a last owner and this is still the belt that keeps that + // death off the client's allocator. But an enqueue from an UNBARRIERED record is a + // FINDING, not a service: rule F says such an apply names no client memory at all, so + // a SharedPtr it could be the last owner of means some site is still pinning a + // frontend object across a record and the migration this phase believes it finished is + // not finished. Named once with the kind so the site is findable, and Fatal under + // strict so the lane owns the red rather than a log nobody reads. + if (!MG_Pipe::MGPipeApplierCurrentRecordIsBarriered()) { + if (MG_Config::Ipc.StrictErrors) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"deferred-destroy\"} - an UNBARRIERED " + "apply was the last owner of a frontend object of kind %u (lifetime " + "%llu). CONTRACT-P5E rule F says an unbarriered apply reads no client " + "memory, so nothing it touched should have been a SharedPtr at all", + static_cast(kind), static_cast(lifetimeId)); + std::abort(); + } + MGLOG_E_ONCE("MGPipe: an UNBARRIERED apply deferred the destruction of a frontend " + "object of kind %u - CONTRACT-P5E §2.7's finding: some apply-thread " + "site still holds a frontend SharedPtr across a record", + static_cast(kind)); + } + { + std::lock_guard lock(g_deferredDestroyMutex); + g_deferredDestroys.push_back(MGPipeDeferredDestroy{kind, lifetimeId}); + } + g_deferredDestroyCount.fetch_add(1, std::memory_order_release); + return true; + } + + // P5e (ra), CONTRACT-P5E §2.3. Declared in PipeMutation.h, where its WHY is argued. + // + // NO GUARD ON THE CALLER'S BEHALF: ClientSession calls this only after a barriered apply + // has returned on a run-ahead session, and adding a second "is run-ahead armed" test here + // would be a copy of a decision that belongs on the other side. What this side owns is + // WHICH rows go, and that answer is ReleaseObjectPins' four. + // + // THE STAMPS ARE LEFT ALONE, deliberately. A released row reads back as null, and a + // BARRIERED verb's fill re-copies it before that verb's apply can pull it (§3.1 skips the + // fill only for UNBARRIERED records); an unbarriered apply may not read it at all, and + // §3.3's detector is what says so by name. Clearing the stamps as well would trade that + // named abort for the poison Fatal, which names the field but not the rule it broke. + void MGPipeReleaseResidualFillPins() { MGPipeFillAccess::ReleaseObjectPins(gPipeInputs); } + + // P5e (ra), CONTRACT-P5E §2.5. Declared in PipeMutation.h; see there for why it is not a + // ClientSession call at the GL entry point. + void MGPipeClientFinish() { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + if (MG_Remote::Client::RunsAsTheServerRole()) return; + if (MG_Remote::Client::ClientSession* session = MG_Remote::Client::ClientSession::Active()) { + session->Finish(); + } + } + + void MGPipeDrainDeferredDestroys() { + if (g_deferredDestroyCount.load(std::memory_order_acquire) == 0) return; + Vector drained; + { + std::lock_guard lock(g_deferredDestroyMutex); + drained.swap(g_deferredDestroys); + g_deferredDestroyCount.store(0, std::memory_order_release); + } + for (const MGPipeDeferredDestroy& one : drained) { + // Each replay lands back in the helper itself, on the GL thread this time, so + // the deferral branch passes and the helper's own order runs unchanged. + switch (one.kind) { + case MGPipeKind::VertexElementsCso: + MGPipeEmitVertexElementsDestroyAndFree(one.lifetimeId); + break; + case MGPipeKind::SamplerViewCso: + MGPipeEmitSamplerViewCsoDestroyAndFree(one.lifetimeId); + break; + case MGPipeKind::Texture: + MGPipeEmitTextureDestroyAndFree(one.lifetimeId); + break; + case MGPipeKind::Renderbuffer: + MGPipeEmitRenderbufferDestroyAndFree(one.lifetimeId); + break; + case MGPipeKind::Framebuffer: + MGPipeEmitFramebufferDestroyAndFree(one.lifetimeId); + break; + case MGPipeKind::SamplerCso: + MGPipeEmitSamplerCsoDestroyAndFree(one.lifetimeId); + break; + case MGPipeKind::ShaderCso: + MGPipeEmitShaderCsoDestroyAndFree(one.lifetimeId); + break; + default: + break; + } + } + } +#endif + + Bool MGPipeEmitVertexElementsDestroyAndFree(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind::VertexElementsCso, + lifetimeId)) { + return false; + } +#endif + // C-1. THE SAME SHAPE AS MGPipeEmitResourceDestroyAndFree ABOVE, and for the same + // reason: whatever mints a handle owns the death of that handle, and the mint for this + // kind is MGPipeVertexInputEmitter::EmitVertexElements - i.e. the client, on every + // backend. Espryt's StateObjectDeathOps notice used to be the only free, so under a + // backend that installs none the slot and the applier's record leaked per VAO, for + // ever. It is now the SECOND, redundant path (Managers.cpp's + // OnFrontendStateObjectDestroyed) and it must stay idempotent, which it is: the + // notice resolves through the same lifetimeId -> slot map this function frees, and + // MGPipeSlotAllocator::Free refuses a slot that is not live at that generation. + // May be the null handle: no slot is minted for a VAO that no draw ever validated with + // and no backend twin table ever looked up. That case still raises the notice below - + // see there. + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::VertexElementsCso, lifetimeId); + + // ASKED, NOT ASSUMED. A slot is not evidence of a record: DirectGLES mints one from + // BackendSlotTable::GetOrCreate at every VAO sync, whether or not bit 8 asked this + // client to emit a create - the shipping 0x7f A/B control arm is exactly that + // configuration. delete_vertex_elements on a handle the applier has no record for is a + // refusal, and the refusal asserts (PipeApply.cpp's ResolveVertexElements), i.e. it + // stops a verify build. + MGPipeVertexInputEmitter& emitter = MGPipeVertexInputEmitterInstance(); + const Bool published = emitter.RecordIsPublished(handle); + if (published) { + MGPHandleOnly only{}; + only.Handle = handle; + only.Kind = static_cast(MGPipeKind::VertexElementsCso); + MGPipeRouteDeleteVertexElements(only); + emitter.NoteRecordDestroyed(handle); + } + + // THE ORDER IS D-L's, WITH THE BACKEND NOTICE IN THE MIDDLE, and each of the three + // positions is load-bearing: + // * the applier's record is dropped FIRST, while nothing else can have re-handed the + // slot out, so a recycled slot cannot inherit a field; + // * the death notice is raised SECOND, because it resolves the handle through the + // allocator and a backend told after the Free below could no longer find its twin + // - which would move the leak from the client to the driver VAO. It is raised + // UNCONDITIONALLY, exactly as ~VertexArrayObject raised it before C-1: whether a + // slot exists is this client's business, and a consumer that records notices (the + // P2 e2 gate does) must not stop seeing this class announce itself; + // * the slot goes back LAST. Espryt's notice frees it too; that Free and this one + // are the same call on the same handle and the second is a no-op, because Free + // bumps no generation (the bump rides the next handout) and refuses a slot that is + // no longer live at this generation. + MG_State::GLState::NotifyStateObjectDestroyed(MGPipeKind::VertexElementsCso, lifetimeId); + if (!MGPipeHandleIsNull(handle)) MGPipeSlots().Free(MGPipeKind::VertexElementsCso, handle); + return published; + } + + // ================================================================================ + // P4a: the BIRTH half - the gate, the four mints, the publication latch and the seam + // ================================================================================ + // + // Declared in MG_Pipe/PipeMutation.h, which is the one door MG_State has into the client + // (the closure gate's mutation-header probe keeps it a declaration), and defined here for + // the reason every other client-side emission point is: this file is package A's for the + // whole phase, so the gate is written ONCE and the packages that own the emitters never + // edit it. + namespace { + using MG_State::GLState::FramebufferObject; + using MG_State::GLState::ITextureObject; + using MG_State::GLState::ProgramObject; + using MG_State::GLState::RenderbufferObject; + using MG_State::GLState::SamplerObject; + + // 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) { + if ((subsystem & kMGPipeP4aFamilySubsystems) == 0) return true; +#if MOBILEGL_BUILD_DISAGGREGATED + // R-8 (c1), the same move as MGPipeResourceSubsystemEnabled's and for the same + // reason. ALL FOUR FAMILIES RIDE THE ONE SIGNAL, exactly as they do in monolith: + // the paragraph above explains why the resource consumer IS the texture family's + // consumer, and the split spelling of "a backend registered the resource op table" + // is "the server published the resource subsystem's consumer bit". Asking per + // family here would be a NEW rule, and a client that withheld more than the server + // refuses leaves the server's handle arm live with no records to read. + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return MG_Remote::Client::CapsMirrorInstance().ServerConsumes( + kMGPipeSubsystemResources); + } +#endif + return 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 `ResolveSubsystemArm()` 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, 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. + // P5e (sb, ID-106 and CONTRACT-P5E.md §1). THE BINDING-POINT FAMILY's TWO EXTRA + // CONJUNCTS, kept beside P4a's rather than folded into them, because it asks a + // DIFFERENT consumer question. P4a's four families all ride the resource family's one + // signal (P4aFamilyHasItsConsumer says why); bit 13's consumer question is bit 13's + // own - MG_Backend/Init.cpp publishes it in the same commit that sets + // ShaderBufferEmit.h's wired constant, and a server that does not publish it is a + // server whose four binding-point walks still read the frontend, so a record sent to it + // would be stored and never looked at while the client latched its suppressor. + // + // AND BIT 13 REQUIRES BIT 7, for bit 11's reason one family over: every + // MGPBufferRange::Res names a Buffer handle and only bit 7 puts one in the resource + // slot table, so without it every EnsureBufferResourceForHandle on the server would + // mint a twin with no record behind it. Withholding the whole family is the safe + // direction - the legacy frontend walk runs untouched on both sides. + Bool P5eFamilyIsLive(Uint64 subsystem, Uint64 pushMask) { + if ((subsystem & kMGPipeSubsystemBufferBindings) == 0) return true; + if ((pushMask & kMGPipeSubsystemResources) == 0) return false; +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + return MG_Remote::Client::CapsMirrorInstance().ServerConsumes( + kMGPipeSubsystemBufferBindings); + } +#endif + // Under monolith the "consumer" is the backend that registered the resource op + // table, exactly as it is for P4a's four: there is no caps snapshot to ask and the + // binding-point walks live in the same DirectGLES that registers it. + return MGPipeGetResourceOps() != nullptr; + } + + Bool FamilyIsLive(Uint64 subsystem, Uint64 wired) { + const Uint64 pushMask = MG_Config::Features.PipePush; + return (pushMask & subsystem) != 0 && (wired & subsystem) != 0 && + P4aFamilyHasItsConsumer(subsystem) && + P4aFamilyDependenciesAreSet(subsystem, pushMask) && + P5eFamilyIsLive(subsystem, pushMask); + } + + // ---- THE FAMILY SEAM ---- + // + // The forwarding from a birth hook to its family's emitter has to be written HERE, + // once, against an emitter whose entry point does not exist yet: A owns this file for + // the whole phase and B/C own the five emit headers, and neither may edit the other's. + // A plain call would not compile against the stub emitter and a runtime `if` would not + // link. So the call is made from a TEMPLATE whose `if constexpr` condition is the + // family's own wired constant, passed as a template ARGUMENT so the condition is + // value-dependent: while the constant is 0 the statement is discarded and never + // instantiated, so this tree compiles against the stubs; the moment a family sets its + // constant the statement instantiates and a missing or misspelled entry point is a + // COMPILE ERROR in that family's own commit rather than a surprise at the merge. That + // is the same property the four `kMGPipeWired*Subsystem == 0 || == its own bit` + // asserts below give, one level further in. + // + // `call` must be a GENERIC lambda - `[&](auto& emitter) { ... }` - so its body is + // checked at instantiation and not at definition. A non-generic one would be checked + // here and would defeat the whole seam. + template + constexpr void ForwardWhenWired(Emitter& emitter, Fn&& call) { + if constexpr (kWired != 0) { + call(emitter); + } else { + (void)emitter; + (void)call; + } + } + + // THE SEAM'S POSITIVE CONTROL, and it is not decoration: every use of it in this tree + // passes a constant that is 0, so the TAKEN arm is never instantiated here and a seam + // that failed to compile or failed to call would be discovered by package B or C + // rather than by the commit that wrote it. This drives both arms against a probe + // emitter shaped like the ones the emit headers will carry, and asserts that exactly + // one call happened - so "discarded when 0, called when set" is a checked property of + // this build rather than a claim in the paragraph above. + struct SeamProbeEmitter { + Uint32 Calls = 0; + constexpr void Probe() { ++Calls; } + }; + + constexpr Bool SeamForwardsExactlyWhenWired() { + SeamProbeEmitter probe{}; + ForwardWhenWired<1ull>(probe, [](auto& emitter) { emitter.Probe(); }); + ForwardWhenWired<0ull>(probe, [](auto& emitter) { emitter.Probe(); }); + return probe.Calls == 1; + } + + static_assert(SeamForwardsExactlyWhenWired(), + "the family seam must forward exactly when its wired constant is non-zero"); + + // ---- THE PUBLICATION LATCH (D-I1) ---- + // + // "Did a create for exactly this handle actually go out?" - asked by the six death + // helpers below and answered by whatever emitted the create. It exists because the + // create is gated at its call site and the destroy inside the helper, so the two ask + // the same question at two different moments; and because A SLOT IS NOT EVIDENCE OF A + // RECORD - a backend twin table mints one through MGPipeSlots().Acquire whether or not + // the subsystem ever asked this client to emit anything, which is exactly what a + // MOBILEGL_PIPE_PUSH lane with P4a's bits clear runs, and a delete_* on such a handle + // is a refused call the applier asserts on in a verify build. + // + // KEYED BY {kind, slot, gen}, so a recycled slot cannot inherit its predecessor's + // answer - the same reason the identity carries a generation at all. + // + // THE ShaderCso COMPOSITE BAND GETS A TABLE OF ITS OWN, exactly as the allocator's + // does and for the same arithmetic: the band's base is 983040, so a single composite + // in a slot-indexed vector would allocate ~983k entries. Anything that indexes a + // ShaderCso slot must test MGPipeIsCompositeShaderSlot(slot) FIRST; this is the + // client-side worked example of that rule. + class MGPipePublicationLatch { + public: + void NotePublished(MGPipeKind kind, MGPipeHandle handle) { + Entry* entry = Grow(kind, handle.Slot); + if (entry == nullptr) return; + entry->Gen = handle.Gen; + entry->Published = true; + } + + Bool IsPublished(MGPipeKind kind, MGPipeHandle handle) const { + const Entry* entry = Find(kind, handle.Slot); + return entry != nullptr && entry->Published && entry->Gen == handle.Gen; + } + + void NoteUnpublished(MGPipeKind kind, MGPipeHandle handle) { + Entry* entry = const_cast(Find(kind, handle.Slot)); + if (entry == nullptr || entry->Gen != handle.Gen) return; + *entry = Entry{}; + } + + private: + struct Entry { + Uint32 Gen = 0; + Bool Published = false; + }; + + static constexpr SizeT kKindCount = static_cast(MGPipeKind::KindCount); + + Bool IsBand(MGPipeKind kind, Uint32 slot) const { + return kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(slot); + } + + Entry* Grow(MGPipeKind kind, Uint32 slot) { + const SizeT index = static_cast(kind); + if (index >= kKindCount) return nullptr; + if (IsBand(kind, slot)) { + const SizeT banded = slot - kMGPipeShaderCsoCompositeSlotBase; + if (banded >= m_band.size()) m_band.resize(banded + 1); + return &m_band[banded]; + } + Vector& table = m_kinds[index]; + if (slot >= table.size()) table.resize(static_cast(slot) + 1); + return &table[slot]; + } + + const Entry* Find(MGPipeKind kind, Uint32 slot) const { + const SizeT index = static_cast(kind); + if (index >= kKindCount) return nullptr; + if (IsBand(kind, slot)) { + const SizeT banded = slot - kMGPipeShaderCsoCompositeSlotBase; + return banded < m_band.size() ? &m_band[banded] : nullptr; + } + const Vector& table = m_kinds[index]; + return slot < table.size() ? &table[slot] : nullptr; + } + + Array, kKindCount> m_kinds{}; + Vector m_band{}; + }; + + MGPipePublicationLatch& PublicationLatch() { + // NEVER DESTROYED, for MGPipeSlots()' reason: the six death helpers reach this + // from frontend destructors that __run_exit_handlers drives AFTER a function-local + // static would have gone, and a destroyed latch answers out of freed vectors. + static MGPipePublicationLatch* latch = new MGPipePublicationLatch(); + return *latch; + } + } // namespace + + void MGPipeNoteHandlePublished(MGPipeKind kind, MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return; + PublicationLatch().NotePublished(kind, handle); + } + + Bool MGPipeHandleIsPublished(MGPipeKind kind, MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return false; + return PublicationLatch().IsPublished(kind, handle); + } + + void MGPipeNoteHandleUnpublished(MGPipeKind kind, MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return; + 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()); + } + + void MGPipeMintRenderbufferHandle(RenderbufferObject& renderbuffer) { + MGPipeSlots().Acquire(MGPipeKind::Renderbuffer, renderbuffer.GetLifetimeId()); + } + + void MGPipeMintFramebufferHandle(FramebufferObject& framebuffer) { + MGPipeSlots().Acquire(MGPipeKind::Framebuffer, framebuffer.GetLifetimeId()); + } + + void MGPipeMintShaderCsoHandle(ProgramObject& program) { + MGPipeSlots().Acquire(MGPipeKind::ShaderCso, program.GetLifetimeId()); + } + + void MGPipeEmitTextureResourceCreate(ITextureObject& texture) { + if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return; + ForwardWhenWired( + MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.EmitResourceCreate(texture); }); + } + + void MGPipeEmitTextureResourceRespecify(ITextureObject& texture, MGPipeTextureRespecifyScope scope, + Uint32 uploadTarget, Uint32 level) { + if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return; + ForwardWhenWired( + MGPipeTextureEmitterInstance(), + [&](auto& emitter) { emitter.EmitResourceRespecify(texture, scope, uploadTarget, level); }); + } + + void MGPipeEmitTextureParams(ITextureObject& texture) { + if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return; + ForwardWhenWired( + MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.EmitTextureParams(texture); }); + } + + void MGPipeNoteTextureLevelDirty(ITextureObject& storageOwner, Uint32 uploadTarget, Uint32 level) { + if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return; + ForwardWhenWired( + MGPipeTextureEmitterInstance(), + [&](auto& emitter) { emitter.NoteLevelDirty(storageOwner, uploadTarget, level); }); + } + + void MGPipeEmitRenderbufferResourceCreate(RenderbufferObject& renderbuffer) { + if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return; + ForwardWhenWired( + MGPipeTextureEmitterInstance(), + [&](auto& emitter) { emitter.EmitRenderbufferCreate(renderbuffer); }); + } + + void MGPipeEmitRenderbufferResourceRespecify(RenderbufferObject& renderbuffer) { + if (!FamilyIsLive(kMGPipeSubsystemTextureResources, kMGPipeWiredTextureSubsystem)) return; + ForwardWhenWired( + MGPipeTextureEmitterInstance(), + [&](auto& emitter) { emitter.EmitRenderbufferRespecify(renderbuffer); }); + } + + void MGPipeNoteTextureBoundAs(MGPipeHandle texture, Uint32 bindBit) { + // Not gated on FamilyIsLive: the mask is client state (see the declaration), and the + // emitter gates the emission it causes. + ForwardWhenWired( + MGPipeTextureEmitterInstance(), + [&](auto& emitter) { emitter.NoteTextureBoundAs(texture, static_cast(bindBit)); }); + } + + void MGPipeNoteTextureImageBound(ITextureObject& texture) { + ForwardWhenWired(MGPipeTextureEmitterInstance(), [&](auto& emitter) { + emitter.NoteTextureBoundAs(emitter.AcquireTexture(texture.GetLifetimeId(), &texture), + static_cast(kMGPipeBindShaderImage)); + }); + } + + void MGPipeEmitSamplerCsoCreate(SamplerObject& sampler) { + if (!FamilyIsLive(kMGPipeSubsystemSamplers, kMGPipeWiredSamplerSubsystem)) return; + ForwardWhenWired( + MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.EmitSamplerCso(sampler); }); + } + + void MGPipeEmitSamplerViewCreate(ITextureObject& texture) { + if (!FamilyIsLive(kMGPipeSubsystemSamplers, kMGPipeWiredSamplerSubsystem)) return; + ForwardWhenWired( + MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.EmitSamplerView(texture); }); + } + + void MGPipeEmitShaderCsoCreate(ProgramObject& program) { + if (!FamilyIsLive(kMGPipeSubsystemPrograms, kMGPipeWiredProgramSubsystem)) return; + ForwardWhenWired( + MGPipeProgramEmitterInstance(), [&](auto& emitter) { emitter.EmitShaderCso(program); }); + } + + // ================================================================================ + // P4a: one client-side death helper per kind P4a mints (D-I1) + // ================================================================================ + // + // BACKEND-NEUTRAL FROM THE FIRST COMMIT, which is the whole point: before P3a's C-1 fix + // the only thing that ever returned a VertexElementsCso slot was DirectGLES' + // StateObjectDeathOps table, so under a backend that installs none every VAO leaked a slot + // and a ~1.3 KB applier record for the life of the process. P4a mints SIX kinds and there + // is no intermediate state in which a backend table is the only path for any of them. + // + // THE THREE-STEP ORDER IS FIXED and each position is load-bearing (see PipeMutation.h): + // wire delete, then the death notice, then the slot free. Each helper returns whether its + // delete actually went out, which is the LATCH taken at the object's create - asking a + // live predicate twice pairs a create emitted under one registration with a destroy gated + // on another, and either direction leaks. + // + // EVERY ONE OF THEM IS PUBLISHED-GATED RATHER THAN SLOT-GATED. A slot is not evidence of a + // record: a backend twin table mints one through MGPipeSlots().Acquire whether or not the + // subsystem ever asked this client to emit a create - which is exactly what a + // MOBILEGL_PIPE_PUSH lane with P4a's bits clear runs - and a delete_* on such a handle is + // a refused call the applier counts and asserts on. So the PUBLICATION LATCH above is + // asked before any delete goes out, and it is the SAME latch whatever emitted the create + // wrote - one answer per {kind, slot, gen}, not a second reading of a live predicate. + // + // THE LATCH RATHER THAN A PER-EMITTER RecordIsPublished(handle), deliberately, and it is + // the one place P4a's shape differs from P3a's: P3a had one kind and one emitter, so the + // emitter could hold the latch. P4a has six kinds behind FOUR emitters and one kind - + // SamplerViewCso - with no frontend object at all, and a ShaderCso whose composite band + // has two independent release paths. A latch this file owns is then the only thing all + // six can read, and it keeps the answer out of the emit headers B and C are writing. + // + // AT THE CONTRACT COMMIT nothing latches a publication, because every family emitter is a + // stub, so every helper here answers false and the legacy path runs unchanged - which is + // what makes this commit behaviourally inert while the SHAPE is already the final one. + namespace { + // Steps 2 and 3, shared: raise the notice while the handle still resolves, then return + // the slot. Raised UNCONDITIONALLY, exactly as the five destructors raised it before + // P4a: whether a slot exists is this client's business, and a consumer that records + // notices must not stop seeing a class announce itself. + void NotifyAndFree(MGPipeKind kind, Uint64 lifetimeId, MGPipeHandle handle) { + MG_State::GLState::NotifyStateObjectDestroyed(kind, lifetimeId); + if (!MGPipeHandleIsNull(handle)) MGPipeSlots().Free(kind, handle); + } + + MGPHandleOnly HandleOnly(MGPipeKind kind, MGPipeHandle handle) { + MGPHandleOnly only{}; + only.Handle = handle; + only.Kind = static_cast(kind); + return only; + } + + // Step 1, shared: the wire delete goes out FIRST and only for a PUBLISHED handle, and + // the latch is cleared with it so a second death path - a composite's two, a backend's + // redundant notice - cannot emit a second delete for a record that is already gone. + // + // `route` IS A MGPipeRoute AND NEVER A MGPipeApply (B1). R-17 converted the + // 40 direct applier CALLS to route calls by renaming `MGPipeApply(` -> but these + // five sites take the entry point BY ADDRESS, `&MGPipeApply`, so the call-expression + // rename missed them and four routed rows (DeleteSamplerView, DeleteShaderState, + // DeleteSamplerState, ResourceDestroy for textures/renderbuffers) still ran the applier + // synchronously on the GL thread under split - two writers on g_applier with the barrier + // not consulted, and under spawn a silent no-op that leaks every one of those objects. + // The parameter type is the route's, which is byte-identical to the applier's + // (const MGPHandleOnly&, void return), so the fix is `&MGPipeRoute` at the five call + // sites; PipeCatalogue.FrontendNeverTakesAnApplierAddress checks MG_Impl in every unit lane. + Bool EmitDeleteIfPublished(MGPipeKind kind, MGPipeHandle handle, + void (*route)(const MGPHandleOnly&)) { + if (!MGPipeHandleIsPublished(kind, handle)) return false; + route(HandleOnly(kind, handle)); + MGPipeNoteHandleUnpublished(kind, handle); + return true; + } + } // namespace + + // THE EMITTER IS TOLD BETWEEN THE WIRE DELETE AND THE FREE (P4a final review C-2), for + // every kind that keeps client state under a handle: a texture's drain entries, pointer, + // cache reference and latches; a renderbuffer's entry; a framebuffer's Named latch; a + // sampler view's and a shader CSO's record memo. Before this the six helpers freed the slot + // and told nobody, so the texture emitter kept the freed ITextureObject* and the level on + // the drain list, and `glTexImage2D; glDeleteTextures; ` called a virtual on freed + // memory from the next validate point. The forward is the P3a shape + // (MGPipeEmitVertexElementsDestroyAndFree's emitter.NoteRecordDestroyed) applied to the + // five P4a kinds that have an entry to retire; the content-addressed sampler CSO keeps + // none per object (its death is the cache's LRU, ID-17). Unconditional in a push build, + // like the mints: the entries exist whether or not the family bit is set. + Bool MGPipeEmitSamplerViewCsoDestroyAndFree(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind::SamplerViewCso, lifetimeId)) { + return false; + } +#endif + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerViewCso, lifetimeId); + const Bool published = + EmitDeleteIfPublished(MGPipeKind::SamplerViewCso, handle, &MGPipeRouteDeleteSamplerView); + ForwardWhenWired( + MGPipeSamplerEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); }); + // THE NOTICE IS RAISED FOR THIS KIND TOO, and the reason it once was not is wrong: + // NotifyStateObjectDestroyed takes a KIND and a lifetime id, not an object + // (StateObjectDeathNotice.h - one entry point for every kind rather than one ops table + // per kind), MGPipeKind has SamplerViewCso, and the view IS keyed in that kind's + // ByLifetimeId map under the texture's id - which is exactly what the FindByLifetimeId + // above just resolved. "It has no frontend object of its own" is why it takes the + // lifetime id; it is not a reason to drop step 2. A backend that holds a twin per + // SamplerViewCso slot - which is the shape both backends' slot tables take - would + // otherwise never be told to drop it, and under a backend with no other per-kind free + // path never drop it at all: the C-1 leak, one kind later, and invisible to + // PipeSlotPeek because the SLOT was returned correctly. + NotifyAndFree(MGPipeKind::SamplerViewCso, lifetimeId, handle); + return published; + } + + Bool MGPipeEmitTextureDestroyAndFree(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind::Texture, lifetimeId)) { + return false; + } +#endif + const MGPipeHandle handle = MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, lifetimeId); + const Bool published = + EmitDeleteIfPublished(MGPipeKind::Texture, handle, &MGPipeRouteResourceDestroy); + // The emitter retires its entry while the handle still resolves (C-2): the drain list + // drops the dead texture's levels, the raw pointer goes, the built-in sampler's cache + // reference is given back, the latches and the sticky mask are cleared. + ForwardWhenWired( + MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteTextureDied(handle); }); + NotifyAndFree(MGPipeKind::Texture, lifetimeId, handle); + // THE SAMPLER VIEW DIES WITH ITS TEXTURE, because it is minted off the same lifetime + // id: one SamplerViewCso per ITextureObject (D-F2), re-issued on the same handle + // whenever the restrictions move. Released AFTER the texture's own record, so a server + // that reads the view to answer "what is this texture" still can while the texture is + // being dropped. + // + // THE BUILT-IN SAMPLER IS NOT RELEASED HERE, and that is a correction to the design + // table rather than an omission: the SamplerObject every ITextureObject owns is a real + // frontend object with its OWN lifetime id and its own #if MOBILEGL_PIPE_PUSH + // destructor, so freeing it from the texture's lifetime id would resolve the wrong slot + // (or, worse, a live one belonging to another object). Its release therefore rides + // ~SamplerObject and MGPipeEmitSamplerCsoDestroyAndFree below - the same helper, the + // same three-step order, idempotent. + // + // WHEN that runs is NOT ordered against this body and nothing here may assume it is. + // m_sampler is a SharedPtr, so a texture unit slot or a sampler-view resolution that + // took a reference delays ~SamplerObject arbitrarily; "a member's destructor follows + // its owner's body" would be true of a by-value member and is not true of this one. + // The conclusion above does not depend on the timing - the two ids are different, so + // the two releases are independent whichever order they happen in - but a package must + // not build an ordering on it. + // + // AND THE VIEW'S ANSWER IS OR-ED IN, not dropped: a texture whose ResourceDestroy was + // suppressed (nothing ever published it) but whose DeleteSamplerView did go out has + // already spoken on the wire for this object, and reporting false would run the legacy + // path for both halves. + const Bool viewPublished = MGPipeEmitSamplerViewCsoDestroyAndFree(lifetimeId); + return published || viewPublished; + } + + Bool MGPipeEmitRenderbufferDestroyAndFree(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind::Renderbuffer, lifetimeId)) { + return false; + } +#endif + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, lifetimeId); + const Bool published = + EmitDeleteIfPublished(MGPipeKind::Renderbuffer, handle, &MGPipeRouteResourceDestroy); + ForwardWhenWired( + MGPipeTextureEmitterInstance(), [&](auto& emitter) { emitter.NoteRenderbufferDied(handle); }); + NotifyAndFree(MGPipeKind::Renderbuffer, lifetimeId, handle); + return published; + } + + Bool MGPipeEmitFramebufferDestroyAndFree(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind::Framebuffer, lifetimeId)) { + return false; + } +#endif + // NO WIRE DELETE EXISTS FOR THIS KIND, and none is invented: PipeCalls.def has + // resource_destroy and the 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. The catalogue is closed. + // + // So the handle is minted and freed entirely client-side and this helper is steps 2 + // and 3 only. 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. + // + // NOTHING EVER TAKES THE PUBLICATION LATCH FOR THIS KIND, by contract and not by + // omission: with no create there is nothing to latch, and with no delete there is + // nothing for a latch to gate. The answer is therefore the literal false rather than a + // latch read, and false is the right one - it means "the legacy path still owes + // whatever it owed", which for a framebuffer is the death notice this just raised. + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::Framebuffer, lifetimeId); + ForwardWhenWired( + MGPipeFramebufferEmitterInstance(), [&](auto& emitter) { emitter.NoteFramebufferDied(handle); }); + NotifyAndFree(MGPipeKind::Framebuffer, lifetimeId, handle); + return false; + } + + Bool MGPipeEmitSamplerCsoDestroyAndFree(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind::SamplerCso, lifetimeId)) { + return false; + } +#endif + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::SamplerCso, lifetimeId); + const Bool published = + EmitDeleteIfPublished(MGPipeKind::SamplerCso, handle, &MGPipeRouteDeleteSamplerState); + // NOTHING TO RETIRE IN AN EMITTER FOR THIS KIND, stated rather than implied: a sampler + // CSO is content-addressed and belongs to a value, so no emitter keeps an entry under + // a SamplerObject's handle - the cache's entries are keyed by value and reference + // count, and the death of a bound sampler object releases its unit's reference at the + // next bind_sampler_states pass (SamplerEmit.h's reconciliation). + NotifyAndFree(MGPipeKind::SamplerCso, lifetimeId, handle); + return published; + } + + Bool MGPipeEmitShaderCsoDestroyAndFree(Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MGPipeDeferDestroyAndFreeIfOnApplyThread(MGPipeKind::ShaderCso, lifetimeId)) { + return false; + } +#endif + // ORDINARY PROGRAMS AND PIPELINE COMPOSITES TAKE THE SAME PATH, deliberately: the + // server never learns a composite is a composite, and the only difference on this side + // is which band the slot came out of. A composite's slot has TWO independent release + // paths - the pipeline cache's LRU eviction and the composite ProgramObject's own + // destructor - and the second 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 (the bump rides the next handout). + const MGPipeHandle handle = + MGPipeSlots().FindByLifetimeId(MGPipeKind::ShaderCso, lifetimeId); + const Bool published = + EmitDeleteIfPublished(MGPipeKind::ShaderCso, handle, &MGPipeRouteDeleteShaderState); + ForwardWhenWired( + MGPipeProgramEmitterInstance(), [&](auto& emitter) { emitter.NoteRecordDestroyed(handle); }); + NotifyAndFree(MGPipeKind::ShaderCso, lifetimeId, handle); + return published; + } + + void MGPipeSetPoisonOmission(const char* verb, const char* field) { + if (verb == nullptr || field == nullptr) { + g_omission = PoisonOmission{}; + return; + } + const auto v = MGPipeFindVerb(verb); + const auto f = MGPipeFindInputField(field); + if (!v || !f) BadKnob("MOBILEGL_PIPE_POISON_OMIT", verb, "unknown verb or field"); + g_omission.Armed = true; + g_omission.Verb = *v; + g_omission.Field = *f; +#if MOBILEGL_PIPE_POISON + MGLOG_I("MGPipe: poison omission armed - %s@%s", field, verb); +#else + MGLOG_W_ONCE("MGPipe: poison omission %s@%s requested but the poison is not compiled in " + "(MOBILEGL_PIPE_POISON=0): no stamp exists to omit", + field, verb); +#endif + } + + // ---- liveness ---- + Bool PipeInputs::IsLive() const { return LiveContext() != nullptr; } + + // ---- the seven F-class forwarders ---- + // + // P5 (R-7.3, CONTRACT-P5.md table 2's "the seven sticky forwards"): each of them now opens + // with MGP_STICKY_FORWARD_PULL. They are THE SEVEN THAT HAND THE SERVER A RAW FRONTEND + // OBJECT OR WRITE INTO THE FRONTEND, and they are also the only fields the poison cannot + // see - they carry no MGP_INPUT_CHECK at all, by the declared exception argued at + // PipeInputs.h's F-class block, so freshness never reaches them and the exit gate was + // structurally blind on exactly the seven most dangerous rows. The hook is a no-op outside + // a server-stamped verb, so InvalidateCompileEnv keeps being reachable from backend + // initialisation - the case the exemption was written for - and every monolith lane, split + // build included, behaves as it does today. +#if MOBILEGL_BUILD_DISAGGREGATED +#define MGP_STICKY_FORWARD_PULL(Field) MGPipeStickyForwardPull(MGPipeInputField::Field) +#else +#define MGP_STICKY_FORWARD_PULL(Field) ((void)0) +#endif + + SizeT PipeInputs::GetBufferBindingPointCount(BufferTarget target) const { + MGP_STICKY_FORWARD_PULL(GetBufferBindingPointCount); + const auto* ctx = LiveContext(); + return ctx != nullptr ? ctx->GetBufferBindingPointCount(target) : 0; + } + + const SharedPtr& PipeInputs::GetProgramObject(Uint index) { + MGP_STICKY_FORWARD_PULL(GetProgramObject); + auto* ctx = LiveContext(); + return ctx != nullptr ? ctx->GetProgramObject(index) : NullShared(); + } + + const SharedPtr& PipeInputs::GetTextureObject(Uint index) { + MGP_STICKY_FORWARD_PULL(GetTextureObject); + auto* ctx = LiveContext(); + return ctx != nullptr ? ctx->GetTextureObject(index) : NullShared(); + } + + Bool PipeInputs::HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const { + MGP_STICKY_FORWARD_PULL(HasOpenTransformFeedbackSpan); + const auto* ctx = LiveContext(); + return ctx != nullptr && ctx->HasOpenTransformFeedbackSpan(lifetimeId); + } + + void PipeInputs::InvalidateCompileEnv() { + MGP_STICKY_FORWARD_PULL(InvalidateCompileEnv); +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // DELETED with an active transport (CONTRACT-P5C §4.2): R-12's caps + // re-publication already invalidates the CLIENT's compile environment when the + // second snapshot arrives (CapsMirror.cpp:78-80), and this forward is a write + // into the frontend with no wire shape. A caller that still needs it under a + // transport is a defect to fix, not a pull to serve. + return; + } +#endif + if (auto* ctx = LiveContext()) ctx->InvalidateCompileEnv(); + } + + Bool PipeInputs::ValidateProgramName(Uint index) const { + MGP_STICKY_FORWARD_PULL(ValidateProgramName); + const auto* ctx = LiveContext(); + return ctx != nullptr && ctx->ValidateProgramName(index); + } + + void PipeInputs::RecordError(ErrorCode code, UniquePtr info) { + MGP_STICKY_FORWARD_PULL(RecordError); +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + // The error queue is CLIENT state and the apply thread may not write it (R4). + // kEventGlError carries the code and the message; the client records it into its + // own queue at the next drain point. ORDERING IS P9's (CONTRACT-P5C §4.2): the + // event is observed at the next EmitAndWait drain, which preserves per-thread + // program order of error-then-read but not cross-verb interleaving - the + // accepted P5c shape, stated in the contract rather than discovered in P6. + const String message = info != nullptr ? info->toString() : String{}; + MG_Remote::Server::ServerSessionInstance().PostGlError(static_cast(code), + message.c_str()); + return; + } +#endif + auto* ctx = LiveContext(); + if (ctx == nullptr) { + MGLOG_E_ONCE("PipeInputs::RecordError: no live context, dropping error %d", static_cast(code)); + return; + } + ctx->RecordError(code, Move(info)); + } +#undef MGP_STICKY_FORWARD_PULL + + // P3a D-H2.1. The draw's RAW vertex-fetch base instance, set immediately before the fill + // at the three *BaseInstance draw entry points. It replaces the ambient process global + // the backend used to read, which is a shape that cannot cross a pushed boundary; the + // value travels as an explicit field of set_vertex_buffers and the SERVER decides + // whether to emulate the fetch shift or let GL_EXT_base_instance do it. + // + // Indirect draws pass nothing: none of the three sites is in an indirect loop, per-command + // base instances are resolved server-side out of the indirect commands, and the client + // emits 0 for every indirect path. + void MGPipeSetPendingBaseInstance(Uint32 baseInstance) { + MGPipeTrackerInstance().SetPendingBaseInstance(baseInstance); + } + + Uint32 MGPipePendingBaseInstance() { return MGPipeTrackerInstance().PendingBaseInstance(); } + + void MGPipeLeaveVerb() { + PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (ra, §3.1): a verb whose fill was skipped has no stamps of this thread's to + // retire, and the bump below would move a serial the SERVER's stamp owns. Leave + // returns, and the tracker's base-instance clear - which is frontend state, not block + // state - still runs at the bottom. + if (g_lastFillWasBarriered) { + // Same layer-2 gate as the fill: the serial bump and the verb reset below are + // writes into gPipeInputs (gt). + MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt( + "MGPipeLeaveVerb", /*isBarrieredFill=*/true); + } + if (!g_lastFillWasBarriered) { + MGPipeTrackerInstance().ClearPendingBaseInstance(); + return; + } +#endif +#if MOBILEGL_PIPE_POISON + // Same bump the next fill would make, without a verb to fill from: no field is + // stamped, so every stamp this verb made falls behind the serial. + ++MGPipeFillAccess::Filled(inputs).CurrentVerbSerial; +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + MGPipeServerClearVerbBoundary(); +#endif + MGPipeFillAccess::SetVerb(inputs, MGPipeVerb::kVerbCount); + // The pending base instance belongs to the verb that was about to run, so leaving + // one drops it. + // + // THIS IS NOT THE CLEAR PRODUCTION RELIES ON, and saying so is better than implying + // two independent guarantees where there is one: no GL entry point calls + // MGPipeLeaveVerb - grep finds MG_Test/ScopedPipeVerb.h and MG_Test/Pipe/TrackerTest + // .cpp and nothing else - so what this line guarantees is that a unit case which + // opens a ScopedPipeVerb cannot leak a base instance into the next case. The + // production property ("consumed by exactly the verb whose entry point set it, and 0 + // at every other Update") is held by MGPipeValidateForVerb, on both of its exits. + MGPipeTrackerInstance().ClearPendingBaseInstance(); + } + + + // ================================================================================ + // The emission step (P2 brief D1 step 3, D5, D6, D7) + // ================================================================================ + namespace { + // Which runtime MOBILEGL_PIPE_PUSH subsystem owns a field, through the call that now + // supplies it. Zero means "still pulled". + constexpr Uint64 SubsystemForEmitter(MGPipeFieldEmitter emitter) { + switch (emitter) { + case MGPipeFieldEmitter::BindRenderState: + case MGPipeFieldEmitter::CreateRenderState: + case MGPipeFieldEmitter::SetDynamicState: + return kMGPipeSubsystemRenderState; + case MGPipeFieldEmitter::SetPatchState: + return kMGPipeSubsystemPatchState; + case MGPipeFieldEmitter::SetVertexAttribDefaults: + return kMGPipeSubsystemVertexAttribDefaults; + // P3a. bind_vertex_elements is the vertex-input family's only emitter row today + // (Coverage.def says why the other two candidates are not there); the resource + // family has none at all, because its calls are dispatched at the GL call that + // causes them rather than filled into a PipeInputs field. + case MGPipeFieldEmitter::BindVertexElements: + return kMGPipeSubsystemVertexInput; + // P4a's six emitted rows, across three of its four subsystems. The fourth, + // kMGPipeSubsystemTextureResources, names NO emitted field and cannot: the texture + // and renderbuffer resource_* calls and set_texture_params are dispatched at the + // GL call that causes them rather than filled into a PipeInputs field, exactly as + // P3a's buffer family is, so there is no Coverage.def emitted row for them and + // there must not be one. + case MGPipeFieldEmitter::SetFramebufferState: + return kMGPipeSubsystemFramebuffer; + case MGPipeFieldEmitter::SetSamplerViews: + case MGPipeFieldEmitter::SetShaderImages: + return kMGPipeSubsystemSamplers; + case MGPipeFieldEmitter::SetDrawProgram: + case MGPipeFieldEmitter::SetDispatchProgram: + return kMGPipeSubsystemPrograms; + // P5c rv (CONTRACT-P5C.md §5.3): the residual-value record rides the residual + // subsystem - the one family with no dirty bit of its own, which is why its + // emission gate is the subsystem bit plus the whole-record hash and nothing else. + case MGPipeFieldEmitter::SetContextValues: + return kMGPipeSubsystemResidualValues; + // P5e's one emitted row (MG_Remote/CONTRACT-P5E.md §5.6): the indexed buffer + // binding points. Written HERE at the contract commit, with the Coverage.def row + // and EmittedCallSuppliesTheWholeField's arm beside it, for the reason + // kMGPipeWiredSubsystems' block states one paragraph down - this file belongs to + // the contract package for the whole phase, and an emitter enumerator whose + // dispatch arm lived in the family's own worktree is the merge trap that block + // exists to close. It is inert until that family's wired constant leaves 0. + case MGPipeFieldEmitter::SetShaderBuffers: + return kMGPipeSubsystemBufferBindings; + case MGPipeFieldEmitter::kNone: + break; + } + return 0; + } + + // The two maps answer different questions - this one takes a field's EMITTER, the + // tracker's MGPipeSubsystemForDirty takes a dirty BIT - and they must agree, because + // the emission is gated on one and the residual-fill skip on the other. A divergence + // would push a call whose field is still pulled, or (worse) skip a field whose call + // was never emitted. Cheap to state, impossible to drift: + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::BindRenderState) == + MGPipeSubsystemForDirty(MGPipeDirty::NewPipelineState), + "bind_render_state and NEW_PIPELINE_STATE must name one subsystem"); + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetDynamicState) == + MGPipeSubsystemForDirty(MGPipeDirty::NewRenderState), + "set_dynamic_state and NEW_RENDER_STATE must name one subsystem"); + // set_pixel_pack_state has no emitter row on purpose (Coverage.def, above + // MGP_COVERAGE_EMITTED_LIST): it carries the PACK half of PipeInputs::m_pixelStore[2] + // only, so the field keeps going through the residual fill loop and no field may be + // skipped on its account. The NEW_PIXEL_PACK bit still names the subsystem the call + // belongs to, which is what the emission gate consults. + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewPixelPack) == kMGPipeSubsystemPixelPack, + "NEW_PIXEL_PACK must name the pixel-pack subsystem"); + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetPatchState) == + MGPipeSubsystemForDirty(MGPipeDirty::NewPatchState), + "set_patch_state and NEW_PATCH_STATE must name one subsystem"); + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetVertexAttribDefaults) == + MGPipeSubsystemForDirty(MGPipeDirty::NewVertexAttribDefaults), + "set_vertex_attrib_defaults and NEW_VERTEX_ATTRIB_DEFAULTS must name one subsystem"); + + // P3a's pairing, now stated as the SAME EQUALITY the four above are (contract-review + // m4, closed here). + // + // It was written with an escape hatch - `MGPipeSubsystemForDirty(...) == 0 ||` - because + // at the contract commit Tracker.h's bit 5 / 9 / 10 arms did not exist yet and the + // direct form would have failed for a reason that was not a defect. That hatch was + // explicitly conditional on the dirty half being unmapped, and the dirty half is now + // mapped (Tracker.h:145-148), so it is removed: leaving it would mean a later edit that + // unmapped one of these bits again passed silently, which is precisely what these + // assertions exist to catch. + // + // AND ALL THREE COMPARE AGAINST SubsystemForEmitter, not against the constant. Two of + // them named kMGPipeSubsystemVertexInput directly, which asks a different and weaker + // question: it pins the dirty half to a constant instead of pinning the two MAPS to + // each other, so an emitter row moved onto another subsystem would still satisfy them + // while the emission gate and the residual-fill skip had begun to disagree. C.5's trap + // is exactly that kind of near-miss. bind_vertex_elements is the family's only + // Coverage.def emitter row, so it is the emitter side of all three. + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::BindVertexElements) == + kMGPipeSubsystemVertexInput, + "bind_vertex_elements must name the vertex-input subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexElements) == + SubsystemForEmitter(MGPipeFieldEmitter::BindVertexElements), + "bind_vertex_elements and NEW_VERTEX_ELEMENTS must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewVertexBuffers) == + SubsystemForEmitter(MGPipeFieldEmitter::BindVertexElements), + "set_vertex_buffers and NEW_VERTEX_BUFFERS must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewIndexBuffer) == + SubsystemForEmitter(MGPipeFieldEmitter::BindVertexElements), + "set_index_buffer and NEW_INDEX_BUFFER must name one subsystem"); + // The two vertex views' capacity is one number on both sides of the boundary. This is + // the one translation unit that sees the frontend constant and the MG_Pipe one, so it + // is where they are pinned together; MGPipeTypes.h says so in place. + static_assert(kMGPipeMaxVertexAttribs == + MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS, + "the MGPipe vertex-attribute capacity and the frontend's have drifted"); + + // P5e (MG_Remote/CONTRACT-P5E.md §1, ruling 10), pinned here for the same reason and in + // the same place: this translation unit sees the frontend constant and the MG_Pipe one, + // and nothing else does. 84 is what the emitter may describe and what the wire carries; + // the BACKEND clamps to the device's real GL_MAX_UNIFORM_BUFFER_BINDINGS, as it does + // today, because a client-side clamp would read a device capability from the wrong side. + static_assert(kMGPipeMaxBufferBindingPoints == + MG_State::GLState::BufferBindingPointCount, + "the MGPipe buffer-binding-point capacity and the frontend's have drifted"); + + // ---- P4a's SEVEN pairings, and EVERY ONE OF THEM COMPARES AGAINST + // SubsystemForEmitter RATHER THAN AGAINST A CONSTANT. That is the lesson written out + // twenty lines above and it is not a style preference: naming the subsystem constant + // directly pins the dirty half to a constant instead of pinning the two MAPS to each + // other, so an emitter row moved onto another subsystem would still satisfy the + // assertion while the emission gate and the residual-fill skip had begun to disagree. + // + // One emitter row stands for each family: set_framebuffer_state for the framebuffer, + // set_sampler_views for the sampler family (set_shader_images is the same subsystem + // and is pinned to it below), and set_draw_program for the program family. + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewFramebuffer) == + SubsystemForEmitter(MGPipeFieldEmitter::SetFramebufferState), + "set_framebuffer_state and NEW_FRAMEBUFFER must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplerViews) == + SubsystemForEmitter(MGPipeFieldEmitter::SetSamplerViews), + "set_sampler_views and NEW_SAMPLER_VIEWS must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewSamplers) == + SubsystemForEmitter(MGPipeFieldEmitter::SetSamplerViews), + "bind_sampler_states and NEW_SAMPLERS must name the sampler subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderImages) == + SubsystemForEmitter(MGPipeFieldEmitter::SetShaderImages), + "set_shader_images and NEW_SHADER_IMAGES must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewShader) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDrawProgram), + "create/bind_shader_state and NEW_SHADER must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBindings) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDrawProgram), + "the program family and NEW_SHADER_BINDINGS must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewGlobalConstants) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDispatchProgram), + "set_global_constants and NEW_GLOBAL_CONSTANTS must name one subsystem"); + // And the two program emitters really are one subsystem, which is what makes the two + // assertions above a statement about the family rather than about one call. + static_assert(SubsystemForEmitter(MGPipeFieldEmitter::SetDrawProgram) == + SubsystemForEmitter(MGPipeFieldEmitter::SetDispatchProgram), + "set_draw_program and set_dispatch_program are one family and one A/B"); + + // P5e's pairing, in the same shape and for the same reason as P4a's seven: the two maps + // answer different questions - one takes a field's EMITTER, the other a dirty BIT - and + // the emission is gated on one while the residual-fill skip is gated on the other, so a + // divergence would push a call whose field is still pulled, or skip a field whose call + // was never emitted. Compared against SubsystemForEmitter rather than against the + // constant, because pinning the two MAPS to each other is the statement that cannot + // drift. + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBuffers) == + SubsystemForEmitter(MGPipeFieldEmitter::SetShaderBuffers), + "set_shader_buffers and NEW_SHADER_BUFFERS must name one subsystem"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewConstBuffers) == + MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBuffers), + "the three binding-point bits are one family and one A/B"); + static_assert(MGPipeSubsystemForDirty(MGPipeDirty::NewSoTargets) == + MGPipeSubsystemForDirty(MGPipeDirty::NewShaderBuffers), + "the three binding-point bits are one family and one A/B"); + + // THE TEXTURE-RESOURCE SUBSYSTEM HAS NO DIRTY BIT, and that has to be asserted rather + // than left as an absence: its calls are dispatched from the GL entry points that + // cause them, so a bit that started naming it would gate the emission twice - once at + // the dispatch site and once in the walk - and the two would disagree the first time + // one of them was edited. Exactly the shape NoDirtyBitOwnsTheResidualSubsystem uses. + constexpr Bool NoDirtyBitOwnsTheTextureResourceSubsystem() { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + if (MGPipeSubsystemForDirty(static_cast(i)) == + kMGPipeSubsystemTextureResources) { + return false; + } + } + return true; + } + static_assert(NoDirtyBitOwnsTheTextureResourceSubsystem(), + "a MGPipeDirty bit now owns kMGPipeSubsystemTextureResources: the texture " + "and renderbuffer resource_* calls are dispatched at the GL call that " + "causes them, so a dirty bit would gate them a second time"); + + // The two texture-unit capacities are one number on both sides of the boundary, and + // this is the one translation unit that sees the frontend constant and the MG_Pipe + // one - the same pinning kMGPipeMaxVertexAttribs gets, for the same reason. + static_assert(kMGPipeMaxTextureUnits == + static_cast(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS), + "the MGPipe texture-unit capacity and the frontend's have drifted"); + static_assert(kMGPipeMaxImageUnits == + static_cast(MG_State::GLState::TextureState::MAX_TEXTURE_IMAGE_UNITS), + "the MGPipe image-unit capacity and the frontend's have drifted"); + + // Which of those subsystems THIS BUILD actually emits for. It grows one commit at a + // time, and a field whose emitter is not wired here keeps being pulled - so adding a + // row to Coverage.def can never silently drop a field on the floor before the call + // that carries it exists. + // + // P3a's two were DELIBERATELY ABSENT at the contract commit, because the emitters + // were stubs; each is added by the commit that gives its own emitters their bodies. + // + // NEITHER OF THEM RETIRES A PULL, and saying so is the point of adding them + // deliberately rather than by reflex: + // + // kMGPipeSubsystemResources names NO emitted field at all. SubsystemForEmitter + // above can never return it, because the resource family is dispatched at the GL + // call that causes it rather than filled into a PipeInputs field - there is no + // Coverage.def emitted row for it and there cannot be one. It is here so the + // constant states what this build emits for, which is what an operator reading + // a MOBILEGL_PIPE_PUSH value has to be able to trust. + // + // kMGPipeSubsystemVertexInput names exactly one emitted field, GetBoundVertexArray + // through bind_vertex_elements - and EmittedCallSuppliesTheWholeField below says + // false for it, with the reason. So this bit switches the EMISSION on and + // changes nothing about the fill loop. + // + // THE TWO BITS ARE NOT AN INDEPENDENT A/B IN ONE DIRECTION, and an operator turning + // them on one at a time has to know which: set_vertex_buffers and set_index_buffer + // name their buffers by {slot, gen} whether or not the resource family created a + // record for them, so bit 8 WITHOUT bit 7 sends the server handles it cannot resolve + // and every one of those calls lands in RefusedResourceCalls. Bit 7 without bit 8 is + // fine. Neither the P3a default (0x1ff, both on) nor G12's control (0x7f, both off) + // is in that arm, which is why nothing in the phase trips over it. + // P4a's FOUR ARE NOT WRITTEN HERE AT ALL, and that is the structural half of the + // ownership rule rather than a stylistic choice. This file is the contract package's + // for the entire phase: it carries Coverage.def's enum-coupled switch, the validate + // point and the death helpers, so the packages that fill the emitters in must never + // edit it - which is exactly the merge trap that produced a push and verify build that + // did not compile on the integrated tree while both branches were green apart. So each + // family's bit is the value of a constant DEFINED IN THAT FAMILY'S OWN EMIT HEADER, + // initialised to 0 there and set to the subsystem constant by the commit that gives + // those emitters their bodies. A mistake is then a compile error at the contract + // commit, not at the merge, and no file is touched twice. + // + // The sampler bit covers SamplerEmit.h AND ImageEmit.h: one family, one A/B. + // P5c rv: the residual subsystem joins the wired mask for set_context_values - the + // record's emission is NOT gated on a dirty bit (there is none for the family, + // NoDirtyBitOwnsTheResidualSubsystem says so), so this bit is what the residual-fill + // skip consults for the eight fields the record supplies. + constexpr Uint64 kMGPipeWiredSubsystems = kMGPipeSubsystemRenderState | + kMGPipeSubsystemPixelPack | + kMGPipeSubsystemPatchState | + kMGPipeSubsystemVertexAttribDefaults | + kMGPipeSubsystemResidualValues | + kMGPipeSubsystemResources | + kMGPipeSubsystemVertexInput | + kMGPipeWiredFramebufferSubsystem | + kMGPipeWiredTextureSubsystem | + kMGPipeWiredSamplerSubsystem | + kMGPipeWiredProgramSubsystem | + // P5e (sb): the indexed buffer binding + // points, by the same rule and from the same + // kind of header. ID-106 pins the other half + // of the switch: MG_Backend/Init.cpp's + // consumer mask gains bit 13 in the same + // commit, or R-8 withholds the whole family. + kMGPipeWiredBufferBindingSubsystem; + // Each family constant is either 0 or its own subsystem bit and nothing else. Without + // this a header that set the wrong constant - the sampler bit in the program header, + // say - would switch the wrong family on and every gate would still pass. + static_assert(kMGPipeWiredFramebufferSubsystem == 0 || + kMGPipeWiredFramebufferSubsystem == kMGPipeSubsystemFramebuffer, + "FramebufferEmit.h's wired constant must be 0 or the framebuffer bit"); + static_assert(kMGPipeWiredTextureSubsystem == 0 || + kMGPipeWiredTextureSubsystem == kMGPipeSubsystemTextureResources, + "TextureEmit.h's wired constant must be 0 or the texture-resource bit"); + static_assert(kMGPipeWiredSamplerSubsystem == 0 || + kMGPipeWiredSamplerSubsystem == kMGPipeSubsystemSamplers, + "SamplerEmit.h's wired constant must be 0 or the sampler bit"); + static_assert(kMGPipeWiredProgramSubsystem == 0 || + kMGPipeWiredProgramSubsystem == kMGPipeSubsystemPrograms, + "ProgramEmit.h's wired constant must be 0 or the program bit"); + static_assert(kMGPipeWiredBufferBindingSubsystem == 0 || + kMGPipeWiredBufferBindingSubsystem == kMGPipeSubsystemBufferBindings, + "ShaderBufferEmit.h's wired constant must be 0 or the binding-point bit"); + + // A field an emitted call supplies COMPLETELY, so the residual fill may stop pulling + // it. Two rows of Coverage.def's emitted list do not qualify and each has its reason + // recorded here rather than a silent absence: + // + // GetPixelStoreParameters is BOTH halves of the pixel store (m_pixelStore[0] pack + // and [1] unpack) and set_pixel_pack_state deliberately carries only PACK + // (ARCHITECTURE.md 4.6 D5, MGPipeTypes.h). The unpack half has no carrier at all, + // so the field keeps being pulled and the verify comparator keeps proving it. + // + // GetBoundVertexArray is P3a's row, and Coverage.def asks for the decision to be + // taken HERE, deliberately, rather than inherited from the row's presence. THE + // ANSWER IS NO, and it is not a matter of degree: the field's storage is a + // SharedPtr - a frontend heap reference - and the call that + // supplies it, bind_vertex_elements, carries an eight-byte {slot, gen} handle + // and nothing else. The applier stores that handle in + // MGPipeApplierState::BoundVertexElements; it has no way to produce the pointer, + // and P3a deliberately does not give it one (a payload never contains a pointer, + // and the whole point of the conversion is that the server stops holding + // frontend references). Skipping the pull would leave m_boundVertexArray null on + // every draw of every push build - which is not a subtle staleness, it is every + // backend read of the bound VAO reading nothing. + // + // So the row is EMITTED-AND-STILL-PULLED, exactly like GetPixelStoreParameters: + // the call goes out because the server needs the format, and the field keeps + // coming through the residual fill because the mirror is a pointer only the + // client can hold. What retires the pull is not a better applier - it is P8, + // where the backend stops reading a frontend VAO at all. + // P4a's SIX ROWS ARE ALL FALSE, and five of them for GetBoundVertexArray's exact + // reason: the field's storage is a frontend heap reference - a + // BindingSlot, an ImageTextureBinding, a TextureUnit, two + // SharedPtr - and the calls that supply them carry eight-byte + // {slot, gen} handles and fully resolved descriptors. The applier has no way to + // produce a pointer and P4a deliberately does not give it one: a payload never + // contains a pointer, and the whole point of the conversion is that the server + // stops holding frontend references. Skipping the pull would leave those mirrors + // null on every draw of every push build. What retires them is not a better + // applier, it is the phase where the backend stops reading a frontend object. + // + // GetMaxTouchedTextureUnit was the sixth and its argument was different - a plain + // Int whose carrier (set_sampler_views' Count) is hash-suppressed while the + // high-water mark still moves on a redundant re-bind. P5c rv RETIRED it from this + // list (CONTRACT-P5C.md §5.3): set_context_values carries the mark as a VALUE of + // its own, whole-record suppressed, so the lag the suppressor could introduce is + // gone and the derivation's RECORD_SUPPLIED answer is honest. + constexpr Bool EmittedCallSuppliesTheWholeField(MGPipeInputField field) { + switch (field) { + case MGPipeInputField::GetPixelStoreParameters: + case MGPipeInputField::GetBoundVertexArray: + case MGPipeInputField::GetFramebufferBindingSlot: + case MGPipeInputField::GetImageTextureBinding: + case MGPipeInputField::GetTextureUnitObject: + case MGPipeInputField::GetProgramForDraw: + case MGPipeInputField::GetProgramForDispatch: + // P5e (CONTRACT-P5E.md §5.6): the same answer for the same reason. The field is + // four raw bases into the frontend's binding-point table and set_shader_buffers + // carries resolved {handle, offset, size} ranges; the applier cannot produce a + // pointer, so skipping the pull would leave the mirror null on every draw of every + // push build. What retires it is the four Espryt consumers reading the applier's + // BoundShaderBuffers, not this row. + case MGPipeInputField::GetBufferBindingPoint: + return false; + default: + return true; + } + } + + // The fields the applier writes DIRECTLY, out of the chunk bytes it scattered. Every + // other emitted field reaches PipeInputs only through + // MGPipeDeriveRenderStateFields, which is why the probe below exists. + constexpr Bool AppliedWithoutDerivation(MGPipeInputField field) { + switch (field) { + case MGPipeInputField::GetRenderStateParameters: + case MGPipeInputField::GetRenderStateParametersVersion: + case MGPipeInputField::GetPipelineStateVersion: + case MGPipeInputField::GetPixelStoreParameters: + case MGPipeInputField::GetPatchVertices: + case MGPipeInputField::GetPatchDefaultOuterLevel: + case MGPipeInputField::GetPatchDefaultInnerLevel: + case MGPipeInputField::GetCurrentVertexAttribute: + // P5c rv's eight: MGPipeApplySetContextValues writes them out of the record, + // field for field, through MGPipeApplyAccess::SetContextValues. + case MGPipeInputField::GetActiveTextureUnit: + case MGPipeInputField::GetMaxTouchedTextureUnit: + case MGPipeInputField::GetTouchedBufferBindingPointCount: + case MGPipeInputField::IsTransformFeedbackActive: + case MGPipeInputField::IsTransformFeedbackPaused: + case MGPipeInputField::GetTransformFeedbackGeneration: + case MGPipeInputField::GetBoundTransformFeedbackLifetimeId: + case MGPipeInputField::GetTransformFeedbackCapturedVertices: + return true; + default: + return false; + } + } + + // DOES THIS TREE'S APPLIER ACTUALLY DERIVE? + // + // MGPipeDeriveRenderStateFields is package A's, and on the P2 contract tag it is a + // declared stub whose body lands in A's follow-on commit. A field that reaches + // PipeInputs only through that derivation must NOT be skipped by the residual fill + // while the derivation is a stub: skipping it would leave the mirror unwritten and + // the backend reading a default. + // + // Rather than hard-code which branch this is, the filler asks once: it puts a + // sentinel in a scratch block's working RenderStateParameters, clears the mirror the + // derivation is supposed to recompute, runs the derivation, and looks. The answer is + // latched for the process and costs one compare, once. + // + // It stays useful after A lands: if the derivation is ever deleted or gated off, the + // filler degrades to PULLING those fields instead of rendering a default, which is + // the safe direction. The verify lane and RenderStateSpansTest are what say the + // derivation is CORRECT; this only says it is THERE. + // + // AND IT IS A ONE-FIELD SAMPLE, deliberately: it probes m_clearStencil and nothing + // else, so a PARTIAL derivation - one that recomputes m_clearStencil and forgets, say, + // GetViewport's rounding - flips this latch to true and lets the other mirrors go + // unwritten. That is a real risk of a half-landed package A and the backstop for it is + // the verify lane (which re-reads every field at every backend read), not this probe. + // Widening the probe to all 29 would re-implement the derivation to check it. + Bool ApplierDerivesRenderStateFields() { + static const Bool answer = [] { + // Leak-at-exit, for gPipeInputs' reason: a PipeInputs is never destroyed by + // an exit handler. This one only ever carries render state, but the rule is + // stated over the TYPE rather than over each instance's current contents - + // an instance that grows an O-class write later must not become the next + // exit-time chain starter. + static PipeInputs& probe = *new PipeInputs(); + constexpr Uint32 kSentinel = 0x5a5a5a5au; + MGPipeFillAccess::RenderStateOf(probe).ClearStencil = kSentinel; + MGPipeFillAccess::ClearStencilOf(probe) = 0u; + MGPipeDeriveRenderStateFields(probe); + const Bool derives = MGPipeFillAccess::ClearStencilOf(probe) == kSentinel; + if (!derives) { + MGLOG_W_ONCE("MGPipe: MGPipeDeriveRenderStateFields does not derive on this " + "build - the render-state mirrors stay on the pull path"); + } + return derives; + }(); + return answer; + } + + // ---- the residual fill's SUPPLIED SET, computed once per environment (P5d r3, C) ---- + // + // WHY THIS CACHE EXISTS, AND WHAT IT DOES NOT CHANGE. Step 4 of the validate point asks, + // for every one of the 63 fields and at EVERY verb, whether an emitted call already + // supplied it. The question is the seven-term conjunction spelled below - and it is still + // spelled exactly once, so there is ONE copy of it: five of its terms are constants of the + // FIELD, and the other two are facts about the PROCESS - which subsystems the operator's + // mask carries, and whether the backend consumes the P4a families - that move a handful of + // times in a process's life and never inside a verb. So the conjunction is evaluated per + // field once per distinct environment and read back as a bit. Nothing about WHICH fields + // are copied moves: same expression, same inputs, same answer. + // + // AND THE PROFILE IS WHY IT IS WORTH A CACHE AT ALL. The 2026-09-17 inproc profile + // (Minecraft 26.3-rc-3, view distance 12, ~852 draws/frame) put MGPipeValidateForVerb at + // 4.11% self / 10.9% inclusive of the GL thread, of which MGPipeTracker::Update is 1.46 + // and CopyField 2.54; most of the rest is this walk's per-field predicate. + // P4aFamilyHasItsConsumer alone is a CapsMirror read under split, and it was taken 63 + // times per verb for an answer that is the same 63 times. + // + // THE KEY IS THE WHOLE OF WHAT THE EXPRESSION READS BESIDE THE FIELD ID, which is what + // makes this a memo rather than a latch that goes stale: + // - the push mask (MG_Config::Features.PipePush), which the per-subsystem A/B lanes move; + // - ApplierDerivesRenderStateFields(), a one-shot probe - in the key anyway, so that a + // build where it ever stopped being one-shot cannot keep a stale answer silently; + // - contextValuesWireLive, which flips when a session starts, stops, or tears its tables + // down (P5c rv, CONTRACT-P5C.md 5.3) - the half that must never disagree with the + // emission's half at the validate point, which is why it is PASSED IN rather than + // re-read here; + // - P4aFamilyHasItsConsumer, which flips when the caps mirror adopts a snapshot (R-12: a + // second arrival IS the invalidation) or a backend registers its resource op table. + // ALL FOUR FAMILIES RIDE THE ONE SIGNAL - that is P4aFamilyHasItsConsumer's own rule, + // argued where it is defined - so asking it once for the whole family mask is asking + // it for every family at once, and the rebuild re-asks it per subsystem from the same + // unchanged mirror. It is also the one key input that CANNOT MOVE AN ANSWER TODAY, + // and the static_assert below is that fact's trip wire rather than a claim in prose. + // + // NOT THREAD-LOCAL, AND THAT IS THIS FILE'S EXISTING RULE RATHER THAN A NEW ONE: + // g_residualDue, g_omission and the verify latches beside it are file-scope too, and what + // keeps a single writer on them is the verb barrier (ROADMAP.md's G1 row states it for + // gPipeInputs itself). A validate that runs on the apply thread is inside that barrier by + // construction. + // + // BUT THE ANSWER IS HANDED OUT BY VALUE, WHICH IS NOT THE SAME ARGUMENT (P5d r3 package C + // review, minor 1). Those latches are single BITS: a reader cannot observe one + // half-written. A 2x64-bit mask can, so the rebuild below fills a LOCAL and publishes it + // into the memo in one assignment, and the caller gets a copy rather than a reference into + // storage the next rebuild would zero under it. Sixteen bytes is cheaper at the use site + // than the indirection was anyway - the walk reads the copy 63 times. + + // AND THE FOURTH KEY INPUT CANNOT MOVE AN ANSWER TODAY, WHICH IS A FACT WITH A TRIP WIRE + // RATHER THAN A COMMENT (P5d r3 package C review, the major). + // + // The consumer conjunct sits in the expression because the expression reads it, and the + // key carries it because a key that omits an input the expression reads is how a memo + // goes stale. But every field whose emitter belongs to a P4a family - the five of them: + // GetFramebufferBindingSlot, GetImageTextureBinding, GetTextureUnitObject, + // GetProgramForDraw, GetProgramForDispatch - is ALSO a field + // EmittedCallSuppliesTheWholeField() answers `false` for: their storage + // is a frontend heap reference (a BindingSlot, an ImageTextureBinding, a TextureUnit, + // two SharedPtr) that a payload cannot carry, so the fill pulls them + // whatever the consumer says. The consumer conjunct is therefore DOMINATED: no motion of + // the caps mirror or of MGPipeGetResourceOps() can change one bit of the mask, and no + // unit case can distinguish a key that carries it from one that does not. + // + // WHEN THIS FIRES, that stopped being true - P3b/P4b/P7/P8 gave one of those rows a twin + // the applier can write - and the consumer signal became observable through + // MGPipeResidualFillSuppliesField. At that point the memo's key needs the same + // move-it-and-read-it-back pair steps 1-3 of + // FieldOwnershipTest.TheResidualFillsSuppliedMemoReKeysOnEveryInputThatMovesAnAnswer + // give the other three inputs, and that case's step 4 (which today pins the domination + // instead) has to become it. Do that rather than deleting this line. + constexpr Bool NoP4aFamilyFieldIsWhollySupplied() { + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + const Uint64 subsystem = SubsystemForEmitter(kMGPipeFieldEmittedBy[i]); + if ((subsystem & kMGPipeP4aFamilySubsystems) != 0 && + EmittedCallSuppliesTheWholeField(field)) { + return false; + } + } + return true; + } + static_assert(NoP4aFamilyFieldIsWhollySupplied(), + "a P4a-family field became whole-supplied: the residual fill's supplied-set " + "memo can now answer differently for the consumer signal, so that key input " + "needs the unit case the paragraph above names"); + + // ---- P5e (gl), ID-112: THE SAME TRIP WIRE FOR THE TWO ROWS THAT ONE CANNOT SEE ------ + // + // The assertion above is keyed on the EMITTER'S SUBSYSTEM, so it covers exactly the five + // P4a-family rows. GetBoundVertexArray (P3a's, emitted by BindVertexElements) and + // GetBufferBindingPoint (P5e sb's, emitted by SetShaderBuffers) sit outside + // kMGPipeP4aFamilySubsystems and had therefore no compile-time protection at all. + // + // WHY THAT IS A DEVICE CRASH AND NOT A STYLE POINT. This residual fill is MAGMA'S ONLY + // SOURCE for all seven pointer-backed rows, and Magma dereferences them on its first + // draw: it is in lockstep for the whole of P5e (ID-90) and reads them through + // MagmaP7AllocatorDebtScope. A package retiring a DirectGLES consumer that "tidied up" + // by deleting one of these two rows from EmittedCallSuppliesTheWholeField would not + // break the build - it would SIGSEGV Magma on a phone, which is the exact shape that + // cost this phase 38 scenarios once already (ID-107). So the seven are NAMED, and the + // naming is the deliverable: it converts the most likely mistake of every remaining + // package from a device crash into a build break. + // + // IF THIS ASSERTION FIRED ON YOU: the answer for each row is argued above + // EmittedCallSuppliesTheWholeField and it is the same argument every time - the field's + // storage is a frontend heap reference and no payload may carry a pointer. What retires + // a row is the phase where the BACKEND stops reading a frontend object (P7 for the + // texture/framebuffer/program mirrors, P8 for the VAO), never a consumer-side cleanup + // in the package you are writing. + constexpr MGPipeInputField kMGPipePointerBackedResidualRows[] = { + MGPipeInputField::GetBoundVertexArray, MGPipeInputField::GetBufferBindingPoint, + MGPipeInputField::GetFramebufferBindingSlot, MGPipeInputField::GetImageTextureBinding, + MGPipeInputField::GetTextureUnitObject, MGPipeInputField::GetProgramForDraw, + MGPipeInputField::GetProgramForDispatch, + }; + static_assert(sizeof(kMGPipePointerBackedResidualRows) / sizeof(MGPipeInputField) == 7, + "ID-112 names SEVEN pointer-backed residual rows; this list is the whole of " + "them and a row removed from it is a row with no trip wire"); + constexpr Bool NoPointerBackedRowIsWhollySupplied() { + for (const MGPipeInputField field : kMGPipePointerBackedResidualRows) { + if (EmittedCallSuppliesTheWholeField(field)) return false; + } + return true; + } + static_assert(NoPointerBackedRowIsWhollySupplied(), + "a pointer-backed residual row stopped being pulled (ID-112). This fill is " + "Magma's ONLY source for GetBoundVertexArray, GetBufferBindingPoint and the " + "five P4a-family mirrors, and Magma dereferences them on its first draw - so " + "this is a build break standing in for a device SIGSEGV. Retire the row in " + "the phase that stops the backend reading a frontend object (P7/P8)"); + + struct ResidualFillPlan { + Bool Valid = false; + Uint64 PushMask = 0; + Bool ApplierDerives = false; + Bool ContextValuesWireLive = false; + Bool P4aConsumer = false; + // One bit per FIELD - not per verb class. The class mask is applied at the walk + // exactly as it always was, so "does this verb read this field" stays the walk's + // business and this stays a statement about EMISSION alone. + MGPipeFieldMask Supplied{}; + }; + ResidualFillPlan g_fillPlan; + + MGPipeFieldMask SuppliedFieldMask(Uint64 pushMask, Bool applierDerives, + Bool contextValuesWireLive) { + // THE CONSUMER SIGNAL IS READ ONLY WHERE THE WALK BELOW COULD REACH IT, and that + // guard is not a micro-optimisation - it is what keeps R-8's DIAGNOSTIC COUNTERS + // where they were (P5d r3 package C review, minor 2). In the conjunction below + // P4aFamilyHasItsConsumer sits AFTER `(pushMask & subsystem) != 0`, and it answers a + // constant `true` for every subsystem outside the P4a families - so at a mask that + // carries no P4a bit NO field reaches the caps mirror at all. Asking it here anyway + // would have moved CapsMirror::ServerConsumes' refusal count and its one-shot + // MGLOG_W at exactly the hand-picked A/B lanes (and the bring-up window, CallMask 0) + // where the baseline never touched the mirror, which is the opposite direction from + // the one this change is supposed to move them in. The key stays COMPLETE because + // pushMask is itself in the key: a mask that gains a P4a bit re-keys on the mask. + const Bool p4aConsumer = (pushMask & kMGPipeP4aFamilySubsystems) != 0 && + P4aFamilyHasItsConsumer(kMGPipeP4aFamilySubsystems); + if (g_fillPlan.Valid && g_fillPlan.PushMask == pushMask && + g_fillPlan.ApplierDerives == applierDerives && + g_fillPlan.ContextValuesWireLive == contextValuesWireLive && + g_fillPlan.P4aConsumer == p4aConsumer) { + return g_fillPlan.Supplied; + } + MGPipeFieldMask built{}; + for (SizeT i = 0; i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + // A field a P2 call now supplies is not pulled again - that second pull is exactly + // the cost P2 exists to remove. THE STAMP IS UNCHANGED either way: a stamp says + // "this verb published this field", which is as true of an emitted field as of a + // copied one, and withholding it would abort every backend read of the very fields + // the migration just took over. The stamp is still written at the walk; only the + // QUESTION moved up here. + 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. + // + // AND THE LAST CONJUNCT IS P5c rv's (CONTRACT-P5C.md 5.3): set_context_values has + // NO PRODUCER without a live wire (its emission is transport-gated at the validate + // point), so its eight fields keep being pulled under monolith - G1's byte-for-byte + // rule - and are skipped only when the record really crosses. + const Bool supplied = subsystem != 0 && (subsystem & kMGPipeWiredSubsystems) != 0 && + (pushMask & subsystem) != 0 && + P4aFamilyHasItsConsumer(subsystem) && + P4aFamilyDependenciesAreSet(subsystem, pushMask) && + EmittedCallSuppliesTheWholeField(field) && + (applierDerives || AppliedWithoutDerivation(field)) && + (emitter != MGPipeFieldEmitter::SetContextValues || + contextValuesWireLive); + if (supplied) built.Words[i / 64] |= (Uint64{1} << (i % 64)); + } + // PUBLISHED IN ONE ASSIGNMENT, after the walk - see the paragraph above the struct. + g_fillPlan.Valid = true; + g_fillPlan.PushMask = pushMask; + g_fillPlan.ApplierDerives = applierDerives; + g_fillPlan.ContextValuesWireLive = contextValuesWireLive; + g_fillPlan.P4aConsumer = p4aConsumer; + g_fillPlan.Supplied = built; + return built; + } + + // set_pixel_pack_state. PACK only, deliberately: nothing on the far side of the + // boundary reads unpack state, and the staged-repack upload path does not even issue + // glPixelStorei (ARCHITECTURE.md 4.6 D5). + Uint64 EmitPixelPackState(GLContext& ctx) { + MGPPixelPackState pack{}; + pack.Pack = ctx.GetPixelStoreParameters(false); + MGPipeRouteSetPixelPackState(pack); + return sizeof(MGPPixelPackState); + } + + // set_patch_state. The trio ALSO travels in pipeline chunk P0, and that redundancy is + // a trip wire rather than waste: the applier asserts under verify that the two + // carriers agree. 28 bytes on a state that changes about once per program. + Uint64 EmitPatchState(GLContext& ctx) { + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + MGPPatchState patch{}; + patch.Vertices = live.PatchVertices; + for (SizeT i = 0; i < 4; ++i) patch.Outer[i] = live.PatchDefaultOuterLevel[i]; + for (SizeT i = 0; i < 2; ++i) patch.Inner[i] = live.PatchDefaultInnerLevel[i]; + MGPipeRouteSetPatchState(patch); + return sizeof(MGPPatchState); + } + + // set_vertex_attrib_defaults, behind D11's set-hash suppressor: the RESOLVED set - all + // 32 values, all three views - is hashed on the client and the call does not go out + // when the hash has not moved. That is coalescing rule 4, and this is its one wired + // consumer in P2. + // + // THE PAYLOAD, SINCE P5c rv (CONTRACT-P5C.md §5.3). A CurrentVertexAttributeValue is + // one value in three views, and GLContext CONVERTS between them numerically, so "the + // bytes of one view" is not the value: glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in + // intValue and 0x3FC00000 in floatValue, and every glVertexAttribI4i/ui is a different + // pair again. MGPAttribValue now carries all three views VERBATIM + // (FloatView/IntView/UintView) plus the class the frontend actually wrote + // (GLContext::GetCurrentVertexAttributeClass), and MGPipeApplySetVertexAttribDefaults + // writes each view from its own array - the cross-view conversion's authoritative + // answer is the client's, and the applier no longer reconverts anything. The pre-rv + // shape (one Data[4] memcpied into all three views, ValueClass ignored) is exactly + // what kept this row EMITTED-AND-STILL-PULLED in EmittedCallSuppliesTheWholeField; + // with the applier fixed, the field is RECORD_SUPPLIED outright. + // + // The suppressing memcmp below is over the three VIEWS only, and that is not an + // oversight: the class decides how the views are REBUILT, so two writes that leave + // the three views identical rebuild identically whichever class they carried, and a + // class that moved without moving any view has nothing to publish. + // + // So the emitter CHECKS rather than assumes, the same self-healing shape as + // ApplierDerivesRenderStateFields: after the call it compares the mirror the applier + // wrote against the frontend's value, and when they differ it copies the field itself + // and says so once. That is what keeps the block correct in the window this call used + // to corrupt - a glVertexAttrib4f followed by a non-kDraw verb, where the residual + // fill does not run for this field and nothing else would have put the value back. + // Since rv the applier writes all three views verbatim, so the compare below is + // expected to pass on every emission; it stays armed because it is the one observable + // of that write being verbatim in a window no other gate looks at. + Uint64 g_attribDefaultRepairs = 0; + + // The header of the last set_vertex_attrib_defaults that actually went out. Count == 0 + // means none ever did, because a call that names no attribute is not emitted at all. + // It is the observable for the two things about this call that cannot be read back + // without a poisoned read of m_currentVertexAttribute: that a fresh context republishes + // the COMPLETE set, and that a single moved attribute publishes exactly that one. + MGPVertexAttribDefaults g_attribDefaultLastHeader{}; + + Uint64 EmitVertexAttribDefaults(GLContext& ctx, Bool freshlyPrimed) { + MGPipeTracker& tracker = MGPipeTrackerInstance(); + auto& staged = tracker.StagedAttribDefaults(); + constexpr SizeT kAttribs = MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; + static_assert(kAttribs <= 32, "MGPVertexAttribDefaults::Mask is a Uint32"); + + Array resolved; + for (SizeT i = 0; i < kAttribs; ++i) resolved[i] = ctx.GetCurrentVertexAttribute(static_cast(i)); + + const Uint64 contentHash = XXH64(resolved.data(), sizeof(resolved), 0); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetVertexAttribDefaults, + contentHash)) { + return 0; + } + + // A FRESH CONTEXT PUBLISHES ALL 32, not the difference against a mirror that + // describes a context that is gone. Tracker::Reset() sets the staging mirror to + // AttribDefaults{}, whose NSDMIs are the GL defaults {0,0,0,1} - and a fresh + // GLContext's m_currentVertexAttributes hold exactly those, so the diff below is + // EMPTY on the one walk that must publish everything. The server's mirror is not + // default: MGPipeApplierReset() clears the CSO store and the residual block and + // leaves gPipeInputs.m_currentVertexAttribute holding the PREVIOUS context's + // defaults. So the InvalidateAll() a fresh context does to the set-hash + // suppressor would have been cancelled two lines later by this diff, and the one + // call P2 fully owns would publish nothing across a context change - exactly the + // "memo that serves a stale answer" the tracker's own COMPLETE-state rule + // (Tracker.h) exists to forbid. EmitRenderState has the same arm + // (freshlyPrimed ? kAllDynamicChunks) and the other two calls send whole values. + Array tail{}; + MGPVertexAttribDefaults header{}; + for (SizeT i = 0; i < kAttribs; ++i) { + if (!freshlyPrimed && std::memcmp(&resolved[i], &staged[i], sizeof(resolved[i])) == 0) { + continue; + } + // The class the frontend WROTE, and that class's own bytes. Not a literal 0 + // and not ClassifyVertexAttribType's answer: that one is the SHADER's question + // ("which view does this input consume"), asked at the backend read sites, and + // it says nothing about which view holds the value the other two were + // converted from. + MGPipeFillAttribValue(static_cast(i), resolved[i], + ctx.GetCurrentVertexAttributeClass(static_cast(i)), + tail[header.Count]); + header.Mask |= Uint32{1} << static_cast(i); + ++header.Count; + staged[i] = resolved[i]; + } + if (header.Count == 0) return 0; + g_attribDefaultLastHeader = header; + MGPipeRouteSetVertexAttribDefaults(header, tail.data()); + + // Did the applier reproduce it? Byte for byte, over the attributes this call + // named - anything less would be a mirror that disagrees with the frontend in a + // window no gate looks at. + // + // P5e (ra, CONTRACT-P5E §3.4): NOT UNDER RUN-AHEAD. The mirror is the APPLIER's + // copy of this record, and set_vertex_attrib_defaults is a kWaitNone row - so + // under run-ahead this thread has not waited for the apply and the read races it, + // and the repair below (a CopyField straight into the block) is precisely the + // GL-thread write §3.5 refuses. The client's own authority is `resolved` and + // `g_attribDefaultLastHeader`, which is what it just published; there is nothing + // the mirror could add that the wire does not already carry. A build that ever + // needs the repair arm again has to earn it with a barriered row. +#if MOBILEGL_BUILD_DISAGGREGATED + if (ClientRunsAhead()) return sizeof(MGPVertexAttribDefaults) + header.Count * sizeof(MGPAttribValue); +#endif + const auto* mirror = MGPipeFillAccess::VertexAttribDefaultsOf(gPipeInputs); + Bool reproduced = true; + for (SizeT i = 0; i < kAttribs && reproduced; ++i) { + if ((header.Mask & (Uint32{1} << static_cast(i))) == 0) continue; + reproduced = std::memcmp(&mirror[i], &resolved[i], sizeof(resolved[i])) == 0; + } + if (!reproduced) { + ++g_attribDefaultRepairs; + MGLOG_W_ONCE("MGPipe: MGPipeApplySetVertexAttribDefaults did not reproduce the " + "carried three views on this build - the client is keeping " + "m_currentVertexAttribute authoritative"); + MGPipeFillAccess::CopyField(gPipeInputs, ctx, MGPipeInputField::GetCurrentVertexAttribute); + } + return sizeof(MGPVertexAttribDefaults) + header.Count * sizeof(MGPAttribValue); + } + +#if MOBILEGL_BUILD_DISAGGREGATED + // set_context_values (P5c rv, CONTRACT-P5C.md §5.3): the residual-value record. One + // POD carrying every value-class field no other set_* supplies - the two texture-unit + // counters, the 15 per-target touched-buffer-binding counts and the five XFB values - + // emitted at validate WHEN ANY COVERED VALUE MOVED, which the whole-record hash says: + // there is deliberately no dirty bit for the family (the tracker's value-class dirty + // accounting is untouched - "零新增记账", ARCHITECTURE.md 5.2) and no dirty mask in the + // payload, so a suppressed record means "nothing moved", never "field invalid" (§1). + // + // THE PRODUCER IS TRANSPORT-GATED, and the residual-fill skip for the same eight fields + // is gated on the same answer (MGPipeValidateForVerb's contextValuesWireLive): under + // monolith - or with a transport configured but no live session (the bring-up window, a + // server-role-only fixture) - nothing is emitted and the fields keep being pulled, byte + // for byte as before (G1). + Uint64 EmitContextValues(GLContext& ctx) { + MGPContextValues values{}; + values.ActiveTextureUnit = static_cast(ctx.GetActiveTextureUnit()); + values.MaxTouchedTextureUnit = static_cast(ctx.GetMaxTouchedTextureUnit()); + // The array is indexed by BufferTarget value, all 15 of them (MGPipeTypes.h); + // targets with no binding points answer 0 (BufferState::GetTouchedBindPointCount). + static_assert( + std::extent_v == + static_cast(BufferTarget::BufferTargetCount), + "MGPContextValues' per-target array and BufferTargetCount have drifted"); + for (Uint32 t = 0; t < static_cast(BufferTarget::BufferTargetCount); ++t) { + values.TouchedBufferBindingPointCount[t] = + static_cast(ctx.GetTouchedBufferBindingPointCount(static_cast(t))); + } + values.IsTransformFeedbackActive = ctx.IsTransformFeedbackActive() ? 1 : 0; + values.IsTransformFeedbackPaused = ctx.IsTransformFeedbackPaused() ? 1 : 0; + values.TransformFeedbackGeneration = ctx.GetTransformFeedbackGeneration(); + values.BoundTransformFeedbackLifetimeId = ctx.GetBoundTransformFeedbackLifetimeId(); + values.TransformFeedbackCapturedVertices = ctx.GetTransformFeedbackCapturedVertices(); + // The whole record is the hash input, padding included - `values{}` zeroes it, so + // the pad bytes are defined and the hash is stable. + const Uint64 contentHash = XXH64(&values, sizeof(values), 0); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetContextValues, + contentHash)) { + return 0; + } + MGPipeRouteSetContextValues(values); + return sizeof(MGPContextValues); + } + + // The fields set_context_values supplies. A validate whose verb class reads NONE of + // them skips the record build entirely - the record exists so a verb's reads are + // answered, and a verb that never reads them needs no publication. + constexpr Bool VerbMaskReadsContextValues(const MGPipeFieldMask& mask) { + return MGPipeFieldMaskHas(mask, MGPipeInputField::GetActiveTextureUnit) || + MGPipeFieldMaskHas(mask, MGPipeInputField::GetMaxTouchedTextureUnit) || + MGPipeFieldMaskHas(mask, MGPipeInputField::GetTouchedBufferBindingPointCount) || + MGPipeFieldMaskHas(mask, MGPipeInputField::IsTransformFeedbackActive) || + MGPipeFieldMaskHas(mask, MGPipeInputField::IsTransformFeedbackPaused) || + MGPipeFieldMaskHas(mask, MGPipeInputField::GetTransformFeedbackGeneration) || + MGPipeFieldMaskHas(mask, MGPipeInputField::GetBoundTransformFeedbackLifetimeId) || + MGPipeFieldMaskHas(mask, MGPipeInputField::GetTransformFeedbackCapturedVertices); + } +#endif + + + // set_residual_value_state (P2 brief D9, ARCHITECTURE.md 9.4). + // + // Since P2 the block is one Uint64 of capability bits, and every one of the 35 is + // ALSO answerable from the assembled working block now that the contract closed the + // FramebufferSrgb / DepthClamp / TextureCubeMapSeamless storage holes. That + // redundancy is the whole point: the bits are read HERE from the frontend, and the + // applier compares them against the assembled answer, so the day a later call takes + // a capability over and forgets to carry it the block says so on the next draw + // (Fatal{PipeResidualDiverged, ""}). + // + // Building the carried bits from the ASSEMBLED block instead would make the trip + // wire a tautology, which is exactly the failure P1's entry compare had and P2 is + // paying to remove. + // + // ON THIS BRANCH IT IS STILL HALF A TAUTOLOGY, and saying so is part of the honesty + // the trip wire is for: the applier compares these bits against gPipeInputs' + // capability mirror, and while MGPipeDeriveRenderStateFields is a stub that mirror is + // filled by the residual fill from the SAME IsCapabilityEnabled accessor a few lines + // below. It becomes an independent oracle the moment package A's c1 lands and the + // fill stops copying those fields. What it proves already is that the block is + // emitted, sized and suppressed - the resid= byte class and the one divergence it + // caught during development (GL_DITHER) are that evidence. + // + // Emitted once per context and again whenever the capability set may have moved + // (D9). THE SHUTTER FOR THAT IS NEW_RENDER_STATE, NOT NEW_PIPELINE_STATE, and the + // difference is a hole rather than a nicety: SetCapability's ClipDistance0..7 arms + // are deliberately NOT BumpVersions() (RenderState.cpp says so in as many words), so + // glEnable(GL_CLIP_DISTANCE0) moves m_version alone - and ClipDistance0..7 are 8 of + // the 35 CapabilityInputs this block carries. Arming on the pipeline version would + // leave the trip wire disarmed for those eight for an unbounded window, which is the + // under-firing direction ARCHITECTURE.md 13.2 names as the dangerous one, and no gate + // could see it: a block that is never emitted cannot diverge. + // + // So the arming is the coarsest always-true shutter - either render-state counter + // moved - which is the same answer DirtySurface.def's derivation gives SetCapability. + // It over-fires (a glViewport re-sends 8 bytes and re-runs the compare) and that is + // the intended trade: over-firing costs one 35-bit loop on a verb that already moved + // render state, under-firing renders stale. + Uint64 EmitResidualValueState(GLContext& ctx) { + ResidualValueBlock block{}; + constexpr SizeT kCapabilityCount = static_cast(CapabilityInput::CapabilityInputCount); + static_assert(kCapabilityCount <= 64, "CapabilityBits is a Uint64"); + for (SizeT i = 0; i < kCapabilityCount; ++i) { + if (ctx.IsCapabilityEnabled(static_cast(i))) { + block.CapabilityBits |= Uint64{1} << i; + } + } + MGPipeRouteSetResidualValueState(block); + if (MG_Util::PipeStats::Enabled()) { + // ByteClass::ResidualValueBlock has been a placeholder that "stays at 0 + // until P2" since P0. This is what makes it non-zero. + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::ResidualValueBlock, + sizeof(ResidualValueBlock)); + } + return sizeof(MGPResidualValueState) + sizeof(ResidualValueBlock); + } + + // Set when the capability set may have moved, cleared when the block goes out. It is + // not part of the tracker because it is emission state, not a shutter: the shutter + // (the render-state counter) has already been consumed by the time this is read. + Bool g_residualDue = true; + + // The residual block is the ONE emission whose gate names a subsystem constant + // directly instead of going through MGPipeSubsystemForDirty, and the reason is that + // it has no dirty bit: it carries what has no shutter of its own, which is what makes + // it the residue. That exception is safe only while no dirty bit claims the same + // subsystem - if one ever did, the block would be gated twice and that bit's own + // emission would silently inherit the residual A/B switch. Asserted rather than + // assumed, the same discipline SubsystemForEmitter's five static_asserts use. + constexpr Bool NoDirtyBitOwnsTheResidualSubsystem() { + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + if (MGPipeSubsystemForDirty(static_cast(i)) == + kMGPipeSubsystemResidualValues) { + return false; + } + } + return true; + } + static_assert(NoDirtyBitOwnsTheResidualSubsystem(), + "a MGPipeDirty bit now owns kMGPipeSubsystemResidualValues: route the " + "residual block's gate through MGPipeSubsystemForDirty like every other " + "emission, or the two gates will disagree"); + + constexpr Uint32 kAllDynamicChunks = + static_cast((Uint64{1} << kMGPipeDynamicChunkCount) - 1); + + // create/bind_render_state and set_dynamic_state. Returns the bytes that went on the + // wire, for the payload histogram. + Uint64 EmitRenderState(GLContext& ctx, Uint32 dirty, Bool freshlyPrimed) { + MGPipeTracker& tracker = MGPipeTrackerInstance(); + const RenderStateParameters& live = ctx.GetRenderStateParameters(); + const auto version = static_cast(ctx.GetRenderStateParametersVersion()); + const auto pipelineVersion = static_cast(ctx.GetPipelineStateVersion()); + Uint64 payloadBytes = 0; + + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) { + const MGPipeHandle cso = MGPipeCsoCacheInstance().Acquire(live, payloadBytes); + MGPBindRenderState bind{}; + bind.Cso = cso; + bind.Version = version; + bind.PipelineVersion = pipelineVersion; + MGPipeRouteBindRenderState(bind); + payloadBytes += sizeof(MGPBindRenderState); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::RenderStateCsoBinds, 1); + } + } + + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) { + // The chunk-level suppressor: only the dynamic chunks that differ from what + // the server has. A glViewport sends chunk D0 and nothing else; a + // glClearColor sends D2. An EMPTY mask still sends the 32-byte header, + // because the VERSION is what Magma's dynamic tail gates on and it moved. + const Uint32 chunkMask = + freshlyPrimed ? kAllDynamicChunks + : MGPipeDynamicChunksThatMoved(live, tracker.Staged()); + Array blob; + const SizeT blobBytes = MGPipeDynamicChunkBlobBytes(chunkMask); + MGPipeGatherDynamicChunks(live, chunkMask, blob.data()); + MGPDynamicState dyn{}; + dyn.ChunkMask = chunkMask; + dyn.Version = version; + dyn.Blob.Size = blobBytes; + MGPipeRouteSetDynamicState(dyn, blob.data()); + payloadBytes += sizeof(MGPDynamicState) + blobBytes; + } + + // The staging mirror is what set_dynamic_state diffs against, so it may only be + // advanced by the branch that actually SENT dynamic bytes. Latching it whenever + // either bit fired would, if NEW_PIPELINE_STATE could ever fire alone, claim the + // server holds chunks it never received - and the chunk-level suppressor would + // then never resend them, which is a permanently stale answer with no gate on it. + // + // It cannot fire alone today because BumpVersions() moves both counters + // (RenderState.h), but that is an invariant of ANOTHER package's file. So it is + // asserted here rather than assumed, and the assignment is narrowed to the one + // bit that owns the mirror. + // + // MOBILEGL_ASSERT compiles out in Release/INFO, which is the G1/G3 + // configuration, so the assert itself is a debug/verify-only alarm. THE + // BEHAVIOUR IS SAFE IN EVERY BUILD REGARDLESS, and it is the narrowing below + // rather than the assert that makes it so: if the invariant ever broke in a + // shipping build the mirror would simply not advance, which costs a re-send of + // chunks the server already has and never claims it holds chunks it does not. + MOBILEGL_ASSERT((dirty & MGPipeDirtyBit(MGPipeDirty::NewPipelineState)) == 0 || + (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) != 0, + "NEW_PIPELINE_STATE fired without NEW_RENDER_STATE: RenderState's " + "BumpVersions no longer moves both counters"); + if (dirty & MGPipeDirtyBit(MGPipeDirty::NewRenderState)) { + tracker.Staged() = live; + } + return payloadBytes; + } + + // ---- P3a's three vertex-input emitters (D-G3, D-H3, D-I) ---- + // + // The shape - three functions in the fixed order elements, then buffers, then index, + // after the four P2 emitters - is the contract commit's, so that the commit which + // fills the bodies in does not also have to edit the validate point. All three now + // have bodies and MGPipeSubsystemForDirty maps their bits onto the vertex-input + // subsystem, so `wants()` can be true. + // + // Everything they do lives in MG_Impl/Pipe/VertexInputEmit.h; what is here is the + // adaptation to the validate point's byte-counting contract. + Uint64 EmitVertexElements(GLContext& ctx) { + return MGPipeVertexInputEmitterInstance().EmitVertexElements(ctx); + } + + Uint64 EmitVertexBuffers(GLContext& ctx) { + MGPipeTracker& tracker = MGPipeTrackerInstance(); + return MGPipeVertexInputEmitterInstance().EmitVertexBuffers(ctx, tracker.PendingBaseInstance()); + } + + Uint64 EmitIndexBuffer(GLContext& ctx) { + return MGPipeVertexInputEmitterInstance().EmitIndexBuffer(ctx); + } + + // ---- P4a's seven emitters (D-C, D-D, D-F, D-G, D-H) ---- + // + // THE SHAPE IS THE CONTRACT COMMIT'S, exactly as P3a's three were: seven adapters + // whose bodies live in the five family headers, so the commits that fill those + // emitters in never touch this file. Every one of them returns 0 today. + // + // THE ORDER IS ARCHITECTURE.md 5.4's RECOMMENDED ONE - framebuffer, then program, then + // textures/sampler/image/global constants - and that document is explicit that the + // order is code organisation and NOT a contract: all of a verb's set_*/bind_* must + // complete before the verb, and apart from "a resource create precedes a bind to it" + // there is no ordering requirement between them. The server specialises the shader and + // the pipeline lazily at the verb, from everything it holds at that moment, which is + // what makes deriving the fragColor broadcast count from the framebuffer record legal + // at the verb rather than at the FBO sync. + Uint64 EmitFramebufferState(GLContext& ctx) { + return MGPipeFramebufferEmitterInstance().EmitFramebufferState(ctx); + } + + Uint64 EmitShaderState(GLContext& ctx) { + return MGPipeProgramEmitterInstance().EmitShaderState(ctx); + } + + Uint64 EmitGlobalConstants(GLContext& ctx) { + return MGPipeProgramEmitterInstance().EmitGlobalConstants(ctx); + } + + Uint64 EmitSamplerViews(GLContext& ctx) { + return MGPipeSamplerEmitterInstance().EmitSamplerViews(ctx); + } + + Uint64 EmitSamplerStates(GLContext& ctx) { + return MGPipeSamplerEmitterInstance().EmitSamplerStates(ctx); + } + + Uint64 EmitShaderImages(GLContext& ctx) { + return MGPipeImageEmitterInstance().EmitShaderImages(ctx); + } + + // The texture sub-data DRAIN, and it is the one P4a emitter with no dirty bit over it. + // Its calls are dispatched from the GL entry points that cause them (a constructor, a + // storage definition, a glTexParameter) and the only thing that has to wait for the + // validate point is the accumulated upload, so the gate is the subsystem bit alone. + // With nothing dirty the drain list is empty and this is one test. + Uint64 DrainTextureSubData(GLContext& ctx) { + return MGPipeTextureEmitterInstance().DrainTextureSubData(ctx); + } + + // ---- P5e's two, and they are TWO adapters over THREE records (sb, §5.6) ---- + // + // The split is the DIRTY BITS' and not the classes': bit 15 is the uniform binding + // points and bit 16 is the two writable classes, which share a shutter because a + // storage bind and a counter bind are the same event to every reader of the record. + // Bit 17's family (set_stream_output_targets) has no adapter at all - XFB stays + // lockstep for the whole of P5e (§5.7) - and that absence is the catalogue's split, + // not an omission. + Uint64 EmitConstBuffers(GLContext& ctx) { + return MGPipeShaderBufferEmitterInstance().EmitConstBuffers(ctx); + } + + Uint64 EmitShaderBuffers(GLContext& ctx) { + return MGPipeShaderBufferEmitterInstance().EmitShaderBuffers(ctx); + } + } // namespace + + Uint64 MGPipeVertexAttribDefaultRepairCount() { return g_attribDefaultRepairs; } + MGPVertexAttribDefaults MGPipeVertexAttribDefaultsLastHeader() { return g_attribDefaultLastHeader; } + + // The unit gate's door onto step 4's predicate (PipeFill.h says why it needs one). It + // returns the memo's answer rather than a second copy of the expression, so a case that + // moves one input and reads the answer back is a statement about the MEMO and not about a + // re-implementation of it. + Bool MGPipeResidualFillSuppliesField(MGPipeInputField field, Uint64 pushMask, Bool applierDerives, + Bool contextValuesWireLive) { + return MGPipeFieldMaskHas(SuppliedFieldMask(pushMask, applierDerives, contextValuesWireLive), + field); + } + + // ---- the validate point (P2 brief D1) ---- + void MGPipeValidateForVerb(MGPipeVerb verb) { + PipeInputs& inputs = gPipeInputs; +#if MOBILEGL_BUILD_DISAGGREGATED + // ---- P5e (ra), CONTRACT-P5E §3: WHO OWNS gPipeInputs FOR THIS VERB ---------------- + // + // Under run-ahead the block is SERVER-ROLE MEMORY for an unbarriered record: the + // client does not fill it, does not stamp it and does not withdraw the server's stamp, + // because it will not be parked while the apply runs and every one of those writes + // would race the applier's own reads. What still runs, unchanged, is the tracker walk + // and the emitters (steps 2 and 3): those PRODUCE RECORDS, which is the whole of what + // an unbarriered verb is allowed to hand the server. + // + // `fillOwed` is deliberately a separate name from `barriered`, and that is what makes + // the red-once one line: setting it to `true` restores the old behaviour (a fill for + // every verb) and the guard below then fires Fatal{RoleViolation, "gPipeInputs"} on + // the first unbarriered verb, by name. + const Bool runAhead = ClientRunsAhead(); +#endif + ParsePoisonOmissionKnob(); +#if MOBILEGL_PIPE_VERIFY + ArmVerify(); +#else + // The runtime knob without the compiled comparator is a no-op that would look green; + // this warning is what a lane's arming assertion turns into red. + if (MG_Config::Features.PipeVerify) { + MGLOG_W_ONCE("MGPipe: MOBILEGL_PIPE_VERIFY=1 requested but the comparator is not compiled in " + "(configure with -DMOBILEGL_PIPE_VERIFY=ON)"); + } +#endif + auto* ctx = LiveContext(); + // THE IDENTITY / LIVENESS PAIR IS WRITTEN ON EVERY VERB, run-ahead or not, and that is + // a NAMED DEVIATION from CONTRACT-P5E §3.1's list (see the report): `m_live` and + // `m_contextIdentity` are not FIELDS of the residual model - no FieldOwnership.def row + // owns them, no applier record carries them, and no server stamp can answer them - + // they are "does this process still have a GL context", which every null-context guard + // in the backend reads and which only this thread can know. Two stores, no freshness + // and no stamp, so an unbarriered apply reading them reads a fact about the client's + // process rather than a value the wire owes it. + MGPipeFillAccess::SetIdentity(inputs, ctx); +#if MOBILEGL_BUILD_DISAGGREGATED + // §2.1's predicate, client-side (see ClientVerbIsBarriered). + const Bool barriered = !runAhead || ClientVerbIsBarriered(verb, ctx); + // THE ONE LINE THE RED-ONCE FLIPS: `= true` here is "fill for every verb", the + // pre-P5e behaviour, and it turns the guard below into the abort §3.5 names. + const Bool fillOwed = barriered; + g_lastFillWasBarriered = fillOwed; + if (fillOwed) { + // P5c (gt, CONTRACT-P5C §6 layer 2) / P5e §3.5: the residual fill is THE + // client-side write into gPipeInputs. Under lockstep it is legal because it runs + // before the record is published - the apply thread's in-applier flag is provably + // down here. Under run-ahead it is legal because this record is BARRIERED: this + // thread is about to park behind it. The second argument is which of the two + // claims the caller is making, and a fill that made the second one falsely is the + // named abort. + // + // P5e (ra2): AND IT IS MADE TRUE FIRST. Phase 1 of the block write is three lines + // below - the serial bump, MGPipeServerClearVerbBoundary() and SetVerb - and every + // one of them is a scalar the apply thread reads at each field access inside the + // record it is currently applying. Withdrawing the server's own stamp from under it + // is what produced `Fatal{UnmigratedPipeInput, "@"}` on the apply thread (report §2). THE RED-ONCE IS THIS LINE: + // delete it and the guard below aborts with Fatal{RoleViolation, "gPipeInputs"} + // naming MGPipeValidateForVerb, because the guard now tests the fact rather than + // taking the claim. + QuiesceApplierBeforeFill("MGPipeValidateForVerb"); + MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt( + "MGPipeValidateForVerb", /*isBarrieredFill=*/barriered); + } +#else + constexpr Bool fillOwed = true; +#endif + if (fillOwed) { +#if MOBILEGL_PIPE_POISON + // Starts at 1: FilledGen == 0 is "never filled", and MGPipeInputFieldIsFresh + // refuses it on both branches, so a read before this first bump is + // Fatal{UnmigratedPipeInput, "@"} rather than default storage. + ++MGPipeFillAccess::Filled(inputs).CurrentVerbSerial; +#endif +#if MOBILEGL_BUILD_DISAGGREGATED + // The client is filling, so whatever the server stamped at its last verb boundary + // is withdrawn: the stamps below are the CLIENT's again and a stale read is a + // defect, not a residual pull. Disarming here rather than at the end of the + // applier's work is what makes the arming flag say "the current stamps are the + // server's" no matter which of the two roles ran last. + // + // P5e (§3.2): under run-ahead this runs only inside a barriered fill, which is the + // whole of the E note's hazard (b.4) - a GL thread withdrawing the SERVER's stamp + // while the apply thread is inside a record that depends on it. For an unbarriered + // verb the stamp is not touched at all, and the applier's own LeaveApplier is then + // its only writer. + MGPipeServerClearVerbBoundary(); +#endif + MGPipeFillAccess::SetVerb(inputs, verb); + } +#if MOBILEGL_PIPE_POISON + MGPipeFilledState& filled = MGPipeFillAccess::Filled(inputs); +#endif + if (ctx == nullptr) { + // The pending base instance belongs to THIS verb, and this exit skips step 3's + // clear, so it has to make the same promise here: a base-instanced draw with no + // live context is a no-op, but leaving its argument standing would hand it to the + // next verb - which, since the tracker's Reset() no longer clears it, is the one + // path that could still carry a stale shift across. + MGPipeTrackerInstance().ClearPendingBaseInstance(); + return; + } + const MGPipeVerbClass verbClass = kMGPipeVerbClass[static_cast(verb)]; + const MGPipeFieldMask& mask = kMGPipeClassFieldMask[static_cast(verbClass)]; + + // ---- step 2: the dirty walk (P2 brief D1, D4) ---- + // The mask is computed, latched and counted here and nothing is emitted from it + // yet: this commit is the safety net that says the walk is semantically free + // before any field stops being pulled. The emission steps land on top of it. + MGPipeTracker& tracker = MGPipeTrackerInstance(); + const Uint32 dirty = tracker.Update(*ctx, verbClass); + + // ---- step 3: emission ---- + // Every gate below goes through MGPipeSubsystemForDirty, the ONE map from a dirty bit + // to the runtime subsystem that owns it. Naming the subsystem constants here instead + // 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. + // + // 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 + // constant. Without this condition a family whose header still says 0 would be CALLED + // at every verb whose bit fires under the shipped default mask, so the commit that + // lands the body would go live one commit early and every gate run in between would + // 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; +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c rv (CONTRACT-P5C.md §5.3): set_context_values is the carrier for the eight + // value-class fields ONLY with a live wire. ONE answer gates both halves - the emission + // below and the residual-fill skip in step 4 - so they can never disagree about who + // supplies a field: with no live session (a monolith transport, the bring-up window, a + // server-role-only fixture) nothing is emitted and the fields keep being pulled, byte + // for byte as before (G1). + const Bool contextValuesWireLive = + MG_Config::Transport != MG_Config::TransportMode::Monolith && + MG_Remote::Client::ContextValuesWireLive(); +#else + constexpr Bool contextValuesWireLive = false; +#endif + // AND THE SEVENTH IS P5eFamilyIsLive (ID-106), which is the same sentence for the + // binding-point family and asks bit 13's OWN consumer bit rather than the resource + // family's. It answers true for every subsystem but bit 13, so nothing that emitted + // before P5e changes. + const auto wants = [&](MGPipeDirty bit) { + const Uint64 subsystem = MGPipeSubsystemForDirty(bit); + return subsystem != 0 && (pushMask & subsystem) != 0 && + (kMGPipeWiredSubsystems & subsystem) != 0 && + P4aFamilyHasItsConsumer(subsystem) && + P4aFamilyDependenciesAreSet(subsystem, pushMask) && + P5eFamilyIsLive(subsystem, pushMask) && + (dirty & MGPipeDirtyBit(bit)) != 0; + }; + Uint64 payloadBytes = 0; + + // A fresh context is a fresh server, and that is true of EVERY subsystem, so it is + // handled BEFORE the per-subsystem gates rather than inside one of them. It used to + // live inside EmitRenderState, which runs only when bit 0 of MOBILEGL_PIPE_PUSH is + // set - so the per-subsystem A/B D14 invites (clear bit 0, keep bits 1..3) gave a + // fresh context a never-reset applier while every other slot WAS invalidated. + // - the CSO cache's handles name slots this client's allocator is about to hand + // out again, so both sides start over together rather than one of them + // remembering the other's objects; + // - what the server has is no longer what any suppressor slot last emitted; + // - and the residual block owes a fresh publication whatever else moved. + if (tracker.FreshlyPrimed()) { +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (ct), CONTRACT-P5C.md §5.1: with an active transport the server's reset + // crosses AS A RECORD, ahead of every reset below - the client-side ones (the CSO + // cache, the hash suppressor, the vertex-input emitter) and the emitters' latches + // - because the record's barrier is what orders the server's MGPipeApplierReset() + // against every verb that follows. The GL-thread direct call it replaces is + // Fatal{RoleViolation, "g_applier"} inside MGPipeApplierReset itself (§6 layer 2), + // so reverting this arm to the direct call goes red by name rather than rendering + // stale. Monolith keeps the direct call, byte for byte (G1). + // + // THE APPLY THREAD IS EXCLUDED (M5's rule): a validate running on the server's own + // thread produces no client record - EmitAndWait there would wait on the thread + // that has to apply the record - and the direct call is exactly what the apply + // thread is allowed to make (the sink's own path runs it there). + // + // AND A CONFIGURED-BUT-WIRELESS TRANSPORT TAKES THE DIRECT CALL TOO: + // EmitApplierResetRecord answers false when no live session could carry the + // record (the pre-Start bring-up window, a ServerLoop fixture with no client at + // all), and in that shape this process IS the only place the reset can run. + const Bool transportActive = + MG_Config::Transport != MG_Config::TransportMode::Monolith && + !MG_Remote::Client::RunsAsTheServerRole(); + Bool resetCrossed = false; + if (transportActive) { + resetCrossed = MG_Remote::Client::EmitApplierResetRecord(); + } +#endif + MGPipeCsoCacheInstance().Reset(); +#if MOBILEGL_BUILD_DISAGGREGATED + if (!resetCrossed) +#endif + MGPipeApplierReset(); + MGPipeSetHashSuppressorInstance().InvalidateAll(); + // P3a: and the vertex-input emitter's latches. NOT because the applier dropped + // its vertex-elements records - it does not, they are share-group object state + // and survive a make-current - but because the emitter's OTHER latch, the bound + // handle, mirrors the applier's BoundVertexElements, which MGPipeApplierReset + // DOES clear. Without this the bind after a make-current would be suppressed as + // unchanged and the server would draw with no vertex elements bound. Re-creating + // an unchanged configuration alongside it is a bounded over-fire; a dropped bind + // is not. The resource tracker is deliberately NOT reset here for the same + // reason its records survive: see ResourceTracker.h's ResetForTest. + MGPipeVertexInputEmitterInstance().Reset(); + // P4a's five, and ONLY their latches: MGPipeApplierReset clears the framebuffer + // records, the three unit sets and the three program handles, so the emitters' + // mirrors of those must 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 bindings. What must NOT reset is the RECORD half - the applier keeps + // its texture, sampler, view and shader-CSO records across a make-current, because + // a GL object lives in a share group, and re-publishing one would move its Serial + // for nothing. + MGPipeFramebufferEmitterInstance().Reset(); + MGPipeTextureEmitterInstance().Reset(); + MGPipeSamplerEmitterInstance().Reset(); + MGPipeImageEmitterInstance().Reset(); + MGPipeProgramEmitterInstance().Reset(); + // P5e (sb): and the binding-point emitter's mirrors. MGPipeApplierReset clears all + // three of the applier's windows and ADVANCES ShaderBuffersSerial, so the emitter's + // latch has to reset with it - the three suppressor slots are already cleared by + // InvalidateAll() above, and without that the first emission after a make-current + // would be suppressed as unchanged and the server would draw against a window it + // had just been told to empty. + MGPipeShaderBufferEmitterInstance().Reset(); + g_residualDue = true; + } + + // P4a's segment, in ARCHITECTURE.md 5.4's RECOMMENDED order - framebuffer, then + // program, then textures / sampler / image / global constants - which is why it stands + // before the render-state block rather than after it. That order is explicitly code + // organisation and not a contract (all of a verb's set_*/bind_* complete before the + // verb, and the server specialises lazily AT the verb from everything it then holds), + // so nothing about the P2 and P3a emissions changes by standing after it; what it buys + // is that the file reads in the order the design states. + // + // ALL SEVEN ARE STUBS AT THE CONTRACT COMMIT and all four family bits are absent from + // kMGPipeWiredSubsystems, so `wants()` is false for every one of them - it tests that + // mask as its third condition, which is what makes the sentence true rather than + // merely intended - and this whole block is dead until the packages that own the + // emitters land. Placing it here, once, is what keeps those packages out of this file. + if (wants(MGPipeDirty::NewFramebuffer)) { + payloadBytes += EmitFramebufferState(*ctx); + } + if (wants(MGPipeDirty::NewShader) || wants(MGPipeDirty::NewShaderBindings)) { + payloadBytes += EmitShaderState(*ctx); + } + // The texture drain has no dirty bit over it (see its definition); it is gated on the + // 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 && + P4aFamilyHasItsConsumer(kMGPipeSubsystemTextureResources) && + P4aFamilyDependenciesAreSet(kMGPipeSubsystemTextureResources, pushMask)) { + payloadBytes += DrainTextureSubData(*ctx); + } + if (wants(MGPipeDirty::NewSamplerViews)) { + payloadBytes += EmitSamplerViews(*ctx); + } + if (wants(MGPipeDirty::NewSamplers)) { + payloadBytes += EmitSamplerStates(*ctx); + } + if (wants(MGPipeDirty::NewShaderImages)) { + payloadBytes += EmitShaderImages(*ctx); + } + if (wants(MGPipeDirty::NewGlobalConstants)) { + payloadBytes += EmitGlobalConstants(*ctx); + } + + // P5e's segment (sb, §5.6): the indexed buffer binding points, AFTER the program + // segment for the same "code organisation, not a contract" reason ARCHITECTURE.md 5.4 + // gives for P4a's order - all of a verb's set_*/bind_* complete before the verb, and + // the server resolves a point against its own descriptor at its sync point. It reads + // better here because the uniform window is what the program's block bindings INDEX + // (DirectGLES.cpp's UBO loop), so the two records that describe a draw's uniform + // buffers stand together. + // + // NOTHING FOR BIT 17: set_stream_output_targets stays unemitted for the whole of P5e + // (§5.7). The bit is still computed, counted and mapped onto this subsystem, so an + // operator clearing bit 13 gets the whole family's frontend walk back. + if (wants(MGPipeDirty::NewConstBuffers)) { + payloadBytes += EmitConstBuffers(*ctx); + } + if (wants(MGPipeDirty::NewShaderBuffers)) { + payloadBytes += EmitShaderBuffers(*ctx); + } + + if (wants(MGPipeDirty::NewPipelineState) || wants(MGPipeDirty::NewRenderState)) { + payloadBytes += EmitRenderState(*ctx, dirty, tracker.FreshlyPrimed()); + } + if (wants(MGPipeDirty::NewPixelPack)) { + payloadBytes += EmitPixelPackState(*ctx); + } + if (wants(MGPipeDirty::NewPatchState)) { + payloadBytes += EmitPatchState(*ctx); + } + if (wants(MGPipeDirty::NewVertexAttribDefaults)) { + payloadBytes += EmitVertexAttribDefaults(*ctx, tracker.FreshlyPrimed()); + } + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c rv (CONTRACT-P5C.md §5.3): the residual-value record, emitted when any covered + // value moved. THE GATE IS THE SUBSYSTEM BIT PLUS THE WIRE BEING LIVE - the family has + // no dirty bit (NoDirtyBitOwnsTheResidualSubsystem) and no P4a consumer predicate (it + // is not one of the four families), and the "did anything move" question is the + // whole-record hash inside EmitContextValues. The class-mask test skips the build for + // verbs that read none of the eight fields. + if (contextValuesWireLive && (pushMask & kMGPipeSubsystemResidualValues) != 0 && + VerbMaskReadsContextValues(mask)) { + payloadBytes += EmitContextValues(*ctx); + } +#endif + + // P3a's vertex segment, in the order the design fixes: vertex elements, then the + // vertex buffers that fill them, then the index binding. All three are LIVE now (m1): + // bits 5 / 9 / 10 map onto kMGPipeSubsystemVertexInput in Tracker.h:145-148 and all + // three emitters have bodies, so `wants()` answers true whenever bit 8 is in the push + // mask - which the phase default 0x1ff sets, on every backend. The sentence that used + // to stand here ("all three still resolve to false today - their dirty bits map to no + // subsystem") was the contract commit's and stopped being true when the client landed. + if (wants(MGPipeDirty::NewVertexElements)) { + payloadBytes += EmitVertexElements(*ctx); + } + if (wants(MGPipeDirty::NewVertexBuffers)) { + payloadBytes += EmitVertexBuffers(*ctx); + } + if (wants(MGPipeDirty::NewIndexBuffer)) { + payloadBytes += EmitIndexBuffer(*ctx); + } + // CONSUMED, so the next verb starts from zero. The tracker's bit-9 shutter read it + // above and EmitVertexBuffers put it on the wire; leaving it set would give the next + // draw the previous draw's fetch shift, which is the exact defect the explicit field + // exists to remove. + tracker.ClearPendingBaseInstance(); + + // ---- step 4: the residual fill, for what an emitted call did NOT supply ---- + // THE SUPPLIED QUESTION IS ASKED ONCE PER ENVIRONMENT, NOT ONCE PER FIELD PER VERB + // (SuppliedFieldMask above, P5d r3 package C): the conjunction it used to spell inline + // here reads nothing about the verb, so it is a memo keyed on the four process facts it + // does read. The two halves of P5c rv's gate still read the ONE `contextValuesWireLive` + // computed at the top of this function - it is the memo's key AND its argument - so they + // cannot disagree about who supplies the eight value-class fields. + // + // P5e (ra, §3.1): AND IT DOES NOT RUN AT ALL FOR AN UNBARRIERED RECORD. Every write + // below - the CopyField and the FilledGen stamp alike - is a GL-thread write into a + // block the apply thread is about to read without this thread being parked, which is + // exactly what rule F forbids. The server does not go without an answer: it stamps its + // own boundary and §3.3's detector turns any field it still needs from here into + // Fatal{UnmigratedPipeInput, "@"} by name, which is what makes the strict + // lane a gate rather than a count. +#if MOBILEGL_BUILD_DISAGGREGATED + // P5e (ra2): PHASE 2 OF THE BLOCK WRITE, AND IT NEEDS ITS OWN WAIT. Everything between + // the phase-1 wait and here PUBLISHED RECORDS - the fourteen emitters above - and the + // apply thread reads gPipeInputs while it applies them. So the window the fill opened at + // the top of this function was closed again by this function's own emissions, and the + // 63-field walk below would run straight into it. Same call, same arm, same no-op + // everywhere run-ahead is not armed; the guard beside it is what turns a missing one + // into a named abort rather than a torn read. + if (fillOwed) { + QuiesceApplierBeforeFill("MGPipeValidateForVerb/residual"); + MG_Remote::Client::ClientSession::RefusePipeInputsTouchWhileApplierOwnsIt( + "MGPipeValidateForVerb/residual", /*isBarrieredFill=*/true); + } +#endif + const Bool applierDerives = ApplierDerivesRenderStateFields(); + // BY VALUE, NOT BY REFERENCE: the memo's storage is rebuilt in place when the key moves, + // and the walk below holds this across 63 iterations. + const MGPipeFieldMask supplied = + SuppliedFieldMask(pushMask, applierDerives, contextValuesWireLive); + for (SizeT i = 0; fillOwed && i < kMGPipeInputFieldCount; ++i) { + const auto field = static_cast(i); + if (!MGPipeFieldMaskHas(mask, field)) continue; +#if MOBILEGL_PIPE_POISON + if (kMGPipeInputFieldSticky[i]) { + // Stamped once by the first fill that sees a live context; fresh through the + // Sticky -> FilledGen != 0 branch of MGPipeInputFieldIsFresh from then on. + if (filled.FilledGen[i] == 0) filled.FilledGen[i] = 1; + continue; + } +#else + if (kMGPipeInputFieldSticky[i]) continue; +#endif + if (!MGPipeFieldMaskHas(supplied, field)) MGPipeFillAccess::CopyField(inputs, *ctx, field); +#if MOBILEGL_PIPE_POISON + // The value is copied either way; only the stamp is withheld for the omitted pair. + if (!IsOmitted(verb, field)) filled.FilledGen[i] = filled.CurrentVerbSerial; +#endif + } + // ---- step 4b: the residual value block, and it goes out HERE ---- + // ARMED OUTSIDE THE SUBSYSTEM GATE: whether the capability set may have moved is a + // fact about the frontend, not about which subsystems this build pushes, and a + // per-subsystem A/B that turns the block off must not also lose the record that one + // is owed. + if ((dirty & (MGPipeDirtyBit(MGPipeDirty::NewRenderState) | + MGPipeDirtyBit(MGPipeDirty::NewPipelineState))) != 0) { + g_residualDue = true; + } + // Its trip wire compares the carried bits against the ASSEMBLED capability mirror, + // and that mirror is written either by the applier's derivation or by the fill loop + // above - so the block is only meaningful once step 4 has run. Emitting it with the + // other calls would compare against the previous verb's answer. + if ((pushMask & kMGPipeSubsystemResidualValues) != 0) { + // The trip wire compares against the ASSEMBLED capability mirror, so it can only + // run at a verb whose class actually carries that mirror - IsCapabilityEnabled is + // in seven of the nine class masks and kQuery and kXfbSpan do not read it, so at + // those verbs the mirror is whatever the last verb that did read it left behind. + // The change is HELD rather than dropped: dropping it would silently disarm the + // wire for a capability that moved between two queries. + if (g_residualDue && MGPipeFieldMaskHas(mask, MGPipeInputField::IsCapabilityEnabled)) { + payloadBytes += EmitResidualValueState(*ctx); + g_residualDue = false; + } + } + if (payloadBytes != 0 && MG_Util::PipeStats::Enabled()) { + // PipeStats::RecordDrawPayloadBytes has been implemented and unit-tested since + // P0 and called by nothing; this is its first emitter, and the 24-bucket + // histogram is what answers ROADMAP.md open question 4's chunk-granularity + // retune with data instead of a guess. + MG_Util::PipeStats::RecordDrawPayloadBytes(payloadBytes); + } +#if MOBILEGL_PIPE_VERIFY + EntryCompare(inputs, mask); +#endif + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/PipeFill.h b/MobileGL/MG_Impl/Pipe/PipeFill.h new file mode 100644 index 000000000..311e3c483 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/PipeFill.h @@ -0,0 +1,176 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/PipeFill.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 +// The fill point (ARCHITECTURE.md 9.2, P1 brief D7). MG_Impl spells MGP_FILL(Verb); as the +// statement immediately before every call through gBackendFunctionsTable.GL - after every +// early return the call is behind, inside the loop body for a call made in a loop - so the +// frontend fills the PipeInputs block for exactly the verbs that reach a backend. In the +// pull build the macro is ((void)0) and the pull build is byte-identical to a tree without +// it. +#if MOBILEGL_PIPE_PUSH +#include +namespace MobileGL::MG_Pipe { + struct PipeInputs; + + // PipeFill.cpp. THE VALIDATE POINT (ARCHITECTURE.md 5.1, P2 brief D1). In order: + // 1. bump the per-verb serial, record the verb and the context identity; + // 2. run the tracker's DIRTY WALK for this verb's class (MG_Impl/Pipe/Tracker.h); + // 3. EMIT, for each set dirty bit whose subsystem bit is on in the runtime + // MOBILEGL_PIPE_PUSH bitmask, the P2 call that carries it; + // 4. run the P1 residual fill for every field an emitted call did NOT supply, + // stamping each with the new serial exactly as before; + // 5. in a verify build, the entry compare against a second snapshot (P1 brief D8) - + // which stops being a tautology the moment step 3 supplies a field step 4 skips. + // + // It was MGPipeFillForVerb through P1, when steps 2 and 3 did not exist. The macro + // spelling, the 83 call sites and the verb enum are unchanged: the dispatch is + // kMGPipeVerbClass's nine classes, which is the same code as nine named ValidateFor* + // entry points with one call site per verb instead of nine. + void MGPipeValidateForVerb(MGPipeVerb verb); + + // Ends the verb in flight without starting another: bumps the serial, so every field the + // verb stamped goes stale, and puts the current verb back to "none", so a read made after + // it aborts as Fatal{UnmigratedPipeInput, "@"} - which is what such a read + // is - instead of naming whichever verb happened to be filled last. Nothing in the GL + // entry points calls this: a real verb is always followed by the next verb's fill. It + // exists for a caller that drives a backend helper directly and wants its declaration to + // 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 `ResolveSubsystemArm()` 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. DOES AN EMITTED CALL SUPPLY THIS FIELD at the environment named, i.e. would + // the validate point's residual fill SKIP it? This is step 4's own predicate, which P5d + // round 3 (package C) turned from a per-field-per-verb conjunction into a memo keyed on the + // three arguments plus the P4a consumer signal it reads for itself - the profile had it at + // 63 CapsMirror reads per verb for one answer. + // + // IT IS EXPORTED FOR THE UNIT GATE AND FOR NO OTHER CALLER, for MGPipeP4aFamilyEmits' + // reason and one of its own: which fields the fill copies has NO other observable, because + // in a push build every emission this predicate asks about is routed straight into the + // applier, which writes the same storage the fill would have written. So the only way to + // state "the memo's key is complete" - the one thing a memo can get wrong that the + // expression it replaced could not - is to ask it directly. A call re-keys the memo, which + // is exactly what the case is for. + // + // AND A STALE ANSWER IS SILENT, WHICH IS WHY THAT CASE IS THE ONLY GUARD. It is tempting to + // say a wrongly-skipped field aborts as Fatal{UnmigratedPipeInput}; it does not. The walk + // stamps FilledGen from the verb serial whether or not it copied (PipeFill.cpp, step 4), so + // a field the fill skips reads FRESH with the PREVIOUS verb's value - a stale binding slot + // rendered without a word, caught only by the verify lane's comparison. Poison catches an + // UNSTAMPED read, not a stamped-but-uncopied one. + Bool MGPipeResidualFillSuppliesField(MGPipeInputField field, Uint64 pushMask, Bool applierDerives, + Bool contextValuesWireLive); + + // PipeFill.cpp. P3a D-H2.1: the DRAW's raw vertex-fetch base instance, which + // set_vertex_buffers now carries as an explicit field. + // + // It replaces an ambient process global the backend read at VAO sync time, which is a + // shape that cannot cross a pushed boundary. The client sends the raw value and never a + // pre-shifted offset: whether to emulate the fetch shift or let GL_EXT_base_instance do + // it is the SERVER's decision. It is also an input to set_vertex_buffers' content hash + // and to the tracker's bit-9 shutter, so a draw whose only change is its base instance + // still reaches the emitter and still goes out. + // + // DO NOT CALL IT DIRECTLY FROM A GL ENTRY POINT - use MGP_SET_BASE_INSTANCE below. This + // whole declaration block is inside #if MOBILEGL_PIPE_PUSH, so a bare call would not even + // compile in a pull build, and the three call sites are in a file that is compiled in + // both. The macro is the same shape MGP_FILL already has, for the same reason. + // + // The validate point consumes and clears it - on both of its exits - and MGPipeLeaveVerb + // clears it too, so a plain draw that follows a base-instanced one sees 0 again. The + // tracker's Reset() deliberately does NOT clear it (Tracker.h): a make-current happens + // BETWEEN the setter and the fill that reads it. + // + // The three GL entry points that make this call (ID-10's grant) are + // MG_Impl/GLImpl/Drawing/GL_Drawing.cpp's DrawElementsInstancedBaseVertexBaseInstance, + // DrawElementsInstancedBaseInstance and DrawArraysInstancedBaseInstance - one line each, + // immediately above the MGP_FILL, carrying the RAW baseinstance argument. + void MGPipeSetPendingBaseInstance(Uint32 baseInstance); + // What the next set_vertex_buffers will carry. The unit gate reads it to pin that a + // make-current between the setter and the fill does not eat it + // (TrackerWalk.ABaseInstanceSurvivesTheFirstWalkOnAFreshContext). + Uint32 MGPipePendingBaseInstance(); + + // PipeFill.cpp. Negative control B (P1 brief D6): the filler withholds the STAMP - never + // the value - of `field` at `verb`, so that verb's read of it is + // Fatal{UnmigratedPipeInput, "Field@Verb"} while every other verb is unaffected. The + // MOBILEGL_PIPE_POISON_OMIT knob (":") calls this once, on the first + // fill; tests call it directly. Both null clears the omission. An unknown name is + // Fatal{PipeVerifyBadKnob}. + void MGPipeSetPoisonOmission(const char* verb, const char* field); + + // PipeFill.cpp. How many times set_vertex_attrib_defaults' applier failed to reproduce + // the value the call carried, so the client wrote the mirror itself + // (EmitVertexAttribDefaults). It is the ONE observable of that repair: the window it + // covers is a verb whose class does not read m_currentVertexAttribute, where reading the + // storage to check it would be the poison violation the fill table exists to forbid. So + // TrackerShippedEmitter asserts on this counter instead. Since P5c rv the record carries + // all three views verbatim (CONTRACT-P5C.md §5.3) and the applier writes each from its own + // array, so the counter is expected to stay at 0 - the check that increments it is the + // trip wire that remains. + // + // Not hot-path instrumentation: it is incremented only inside the repair branch, which + // runs only when the call actually went out, which is only when an attribute default + // moved. + Uint64 MGPipeVertexAttribDefaultRepairCount(); + + // PipeFill.cpp. The header of the last set_vertex_attrib_defaults that actually went out + // - Mask, and Count == 0 for "none ever did", since a call naming no attribute is not + // emitted. Two properties of this call have no other observable, because reading + // m_currentVertexAttribute back at a verb whose class does not carry it is the poison + // violation the fill table exists to forbid: that a FRESH CONTEXT republishes all 32 + // (the server's mirror still holds the previous context's defaults), and that one moved + // attribute publishes exactly one. Eight bytes, written only when a call goes out. + MGPVertexAttribDefaults MGPipeVertexAttribDefaultsLastHeader(); + +#if MOBILEGL_PIPE_VERIFY + // PipeFill.cpp. The second arm of the comparator (P1 brief D8, ARCHITECTURE.md 13.2-2): + // fills `snapshot` from the live GLContext the old way, for every field in `mask`. This + // is the branch that survives P13, which is why it is its own function rather than the + // filler's loop. + void SnapshotFromGLContext(PipeInputs& snapshot, const MGPipeFieldMask& mask); +#endif +} // namespace MobileGL::MG_Pipe +#define MGP_FILL(Verb) ::MobileGL::MG_Pipe::MGPipeValidateForVerb(::MobileGL::MG_Pipe::MGPipeVerb::Verb) +// P3a D-H2.1. One line immediately ABOVE the MGP_FILL of a draw entry point that takes a +// baseinstance, carrying the argument RAW. It has to be a macro for MGP_FILL's reason: the +// three call sites are compiled in the pull build too, where MGPipeSetPendingBaseInstance is +// neither declared nor defined. +#define MGP_SET_BASE_INSTANCE(BaseInstance) \ + ::MobileGL::MG_Pipe::MGPipeSetPendingBaseInstance(static_cast<::MobileGL::Uint32>(BaseInstance)) +#else +#define MGP_FILL(Verb) ((void)0) +#define MGP_SET_BASE_INSTANCE(BaseInstance) ((void)0) +#endif diff --git a/MobileGL/MG_Impl/Pipe/ProgramEmit.h b/MobileGL/MG_Impl/Pipe/ProgramEmit.h new file mode 100644 index 000000000..02e98b559 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/ProgramEmit.h @@ -0,0 +1,729 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/ProgramEmit.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The CLIENT side of P4a's program family: create/bind/delete_shader_state, +// set_draw_program, set_dispatch_program and set_global_constants. +// +// WHERE create_shader_state IS EMITTED FROM, and why it is not the tracker's business: the +// tracker's bit-6 shutter reads GetCurrentProgram() and DELIBERATELY NOT GetProgramForDraw(), +// because the tracker must not force a compile just to answer "did the shader move". So the +// tracker keeps its shutter and the EMITTER joins - from the same GetProgramForDraw() / +// GetProgramForDispatch() call the verb is about to make anyway, so no join happens that would +// not have happened. Emitting from the compile pool's terminal continuation is a real +// asynchronous win and is a LATER phase's: in monolith the applier is one function call away, +// so it is unmeasurable here. +// +// WHAT THE SERVER STILL SPECIALISES, so nobody reads create_shader_state as self-contained +// and produces a per-draw rebuild: the draw-FBO clamp masks, the fragColor broadcast count, +// the storage-block binding signature, the atomic-counter set, the live image formats and the +// patch parameters are all inputs a backend program depends on BEYOND the artefacts. This call +// publishes the ARTEFACTS; the server specialises at the verb from the state it holds. The +// clause count does not shrink - its inputs move. +// +// THE ARTEFACTS DO NOT TRAVEL IN MONOLITH. All seven of MGPProgramDesc's blob refs are +// declared with Size 0 and the LinkArtifacts / SpirvArtifacts ride beside the record through +// MGPipeApplyCreateShaderState's companion pointers, so the codec is never called on the hot +// path; the verify build is where it is exercised. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + // WIRED. create/bind/delete_shader_state, set_draw_program, set_dispatch_program and + // set_global_constants all have bodies, so this family contributes its bit to + // kMGPipeWiredSubsystems. + // + // AND SINCE c0b THAT CONSTANT REALLY IS PART OF THE EMISSION GATE, so the note that used to + // say otherwise here was true only against the contract commit: the validate point's + // `wants()` asks the subsystem mapping, the operator's MOBILEGL_PIPE_PUSH mask, THIS + // CONSTANT and the dirty bit, and the birth hooks' `FamilyIsLive` asks the same pair one + // level in. It is also a compile-time contract - while it is non-zero PipeFill.cpp's + // `if constexpr` seam instantiates the forward to EmitShaderCso below, so a missing entry + // point is a build error here rather than at the merge. The RUNTIME A/B that switches the + // family off is still the mask. See SamplerEmit.h's twin note. + inline constexpr Uint64 kMGPipeWiredProgramSubsystem = kMGPipeSubsystemPrograms; + + // D-H6. ~0u is the BACKENDS' "never uploaded" sentinel for a global-constants version, and + // ProgramObject::MarkUBOContentDirty skips it on the wrap for exactly that reason. The + // client must never put it on the wire either: a server that received it would read its own + // record as "nothing has ever been uploaded here" and re-upload for ever. + inline constexpr Uint32 kMGPipeGlobalConstantsNeverUploaded = ~Uint32{0}; + + inline constexpr Bool MGPipeGlobalConstantsVersionIsEmittable(Uint32 version) { + return version != kMGPipeGlobalConstantsNeverUploaded; + } + + // A program's identity for the wire, out of the SNAPSHOT the last link consumed and never + // out of the live attach list: glAttachShader and glCompileShader take effect only at the + // NEXT link and neither moves m_linkVersion, so a stage mask built from GetAttachedShaders + // would describe a program that does not exist yet. GetLinkedShaderStages() is also what + // indexes GetGeneratedSpirv(), so the two halves of this descriptor are guaranteed to agree + // by construction rather than by care. + // P5e (pg). ONE ENTRY OF THE STORAGE-OVERRIDE SIGNATURE, and it is a VERBATIM copy of the + // mix in `MG_Backend/DirectGLES/Managers.cpp`'s ComputeShaderStorageBlockBindingSignature - + // the function whose answer this record replaces on the handle arm. The two halves cannot + // share one definition today because MG_Backend includes nothing from MG_Impl (checked at + // this head), so they are twins with a named pointer at each other and a unit case in + // ProgramEmitTest that pins the number. Order-independent by construction: the source is an + // UnorderedMap, the combine is commutative, and the entry mixes name and binding so two + // entries cannot trade halves and cancel out. + // + // OVER THE VALUES, NOT OVER A CHANGE COUNTER, which is the property the pipeline composite's + // uniform mirror depends on: it re-sets every override every draw, and re-setting a block to + // the binding it already carries must produce the same signature and force no rebuild. + inline Uint64 MGPipeStorageOverrideSignatureEntry(const String& blockName, Int binding) { + Uint64 entry = std::hash{}(blockName); + entry ^= (static_cast(static_cast(binding)) + 0x9e3779b97f4a7c15ull + + (entry << 6) + (entry >> 2)); + return entry; + } + + 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(stage); + } + return mask; + } + + class MGPipeProgramEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + using ProgramObject = MG_State::GLState::ProgramObject; + + // create_shader_state (re-issued on the SAME handle whenever the link version moves - + // Gen moves only on slot reuse), then bind_shader_state and set_draw_program / + // set_dispatch_program. Two program calls because the frontend has two joins and two + // PipeInputs slots. + // + // BOTH JOINS HAPPEN HERE and both are the verb's own: GetProgramForDraw flattens a + // bound pipeline into its composite and GetProgramForDispatch answers the compute + // question, and with a plain glUseProgram they are the same object, so the ordinary + // frame pays one join it was going to pay anyway. + Uint64 EmitShaderState(GLContext& ctx) { + Uint64 bytes = 0; + const auto& drawProgram = ctx.GetProgramForDraw(); + const auto& dispatchProgram = ctx.GetProgramForDispatch(); + + const MGPipeHandle drawCso = + drawProgram ? AcquireShaderCso(*drawProgram, bytes) : kMGPipeNullHandle; + // P5e (pg): AFTER the create and never before it. The draft note on + // MGPProgramBindings said "emitted BEFORE create_shader_state at the same validate + // point", and that ordering is wrong against the applier the same contract + // specifies: a re-issued create CLEARS all three tails, for the reason it clears + // GlobalConstants (a relink replaces the archive the tails index into), so bindings + // published ahead of it are wiped by the create that follows. What the draft was + // protecting - "a rebuild inside the verb must already see the bindings" - is + // satisfied by both records going out at the SAME validate point, ahead of the verb, + // which is what this ordering does. See MG_Remote/CONTRACT-P5E.md §5.5 and the + // record's own comment in PipeApply.h. + if (drawProgram) bytes += EmitProgramBindings(*drawProgram, drawCso); + // 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; + if (dispatchProgram && dispatchProgram != drawProgram) { + bytes += EmitProgramBindings(*dispatchProgram, dispatchCso); + } + + // 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) { + MGPipeRouteBindShaderState(HandleOnly(boundCso)); + m_boundCso = boundCso; + ++m_binds; + bytes += sizeof(MGPHandleOnly); + } + if (drawCso != m_drawCso) { + MGPipeRouteSetDrawProgram(HandleOnly(drawCso)); + m_drawCso = drawCso; + ++m_drawSets; + bytes += sizeof(MGPHandleOnly); + } + if (dispatchCso != m_dispatchCso) { + MGPipeRouteSetDispatchProgram(HandleOnly(dispatchCso)); + m_dispatchCso = dispatchCso; + ++m_dispatchSets; + bytes += sizeof(MGPHandleOnly); + } + return bytes; + } + + // set_global_constants: the DEFAULT UNIFORM BLOCK only, keyed (ShaderCso, Version) and + // at most once per program per frame. Version is GetUBOContentVersion() and must never + // be ~0u, which is the backends' "never uploaded" sentinel - the wrap skips it. + // + // NAMED uniform blocks are NOT this call's: set_shader_buffers(Uniform) is a later + // phase's and BindCurrentProgramWithResources' named-UBO block is untouched. What + // travels here is globalUboScratch, the link phase's CPU array, which has no GL name + // and no BufferObject behind it. + Uint64 EmitGlobalConstants(GLContext& ctx) { + const auto& program = ctx.GetProgramForDraw(); + if (!program) return 0; + const Uint32 version = program->GetUBOContentVersion(); + // THE SENTINEL IS NEVER EMITTED. A server that received ~0u would read its own + // record as "never uploaded" and re-upload every frame for ever. + if (!MGPipeGlobalConstantsVersionIsEmittable(version)) return 0; + const Uint size = program->GetUBOSize(); + if (size == 0) return 0; + + Uint64 bytes = 0; + const MGPipeHandle cso = AcquireShaderCso(*program, bytes); + // (ShaderCso, Version) IS the key, so the latch is the key: an unchanged pair means + // the server already holds these bytes and re-sending them would move the record's + // serial for nothing. + if (cso == m_constantsCso && version == m_constantsVersion) return bytes; + + m_lastConstants = MGPGlobalConstants{}; + m_lastConstants.ShaderCso = cso; + m_lastConstants.Version = version; + // THE ONE BLOB RULE: Size 0 means "this record does not declare its blob" - which + // is what a monolith emission is - and the bytes ride beside it as a companion + // pointer. Offset carries the staging address for diagnostics only; nothing reads + // it as a length. + m_lastConstants.Blob.Seg = kMGHostSpanSegNone; + m_lastConstants.Blob.Offset = reinterpret_cast(program->GetUBOData()); + m_lastConstants.Blob.Size = 0; + // `size` is GetUBOSize(), and it is passed because the record declares 0 - the + // monolith convention (the bytes ride beside the record) that CONTRACT-P5 table 1 + // rule A cannot keep under split. It is the row's largest and least bounded blob, + // per program per frame, so it is also the one R-10's max-record counter watches. + MGPipeRouteSetGlobalConstants(m_lastConstants, program->GetUBOData(), + static_cast(size)); + 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; + } + + // ---- P5e (pg): set_program_bindings, opcode 80 ----------------------------------- + // + // THE THREE POST-LINK MUTABLE REFLECTION FIELDS, and why an archive alone is not an + // answer. `uniformBlockBinding[i]`, `uniformSamplerOrImageUnitIndex[loc]` and the + // name-keyed `shaderStorageBlockBinding` map are all MEMBERS OF LinkArtifacts - so the + // archive create_shader_state carries does hold them - but glUniformBlockBinding, + // glUniform1i on a sampler uniform and glShaderStorageBlockBinding all move them AFTER + // the link that produced that archive, without relinking. A server answering a draw + // from the archive alone would bind the uniform blocks the program was LINKED with + // rather than the ones it is BOUND with. This record is the delta carrier; the applier + // overlays it on the archive, and a re-issued create drops it because the indices no + // longer mean anything. + // + // LATCHED ON THE TWO COUNTERS THAT MOVE WHEN ANY OF THE THREE DOES, plus the handle: + // m_backendStateVersion (glUniformBlockBinding and glUniform1i both bump it) and + // m_blockBindingVersion (glUniformBlockBinding and glShaderStorageBlockBinding both + // bump it, and the storage setter deliberately bumps ONLY it). Neither setter moves a + // counter on an unchanged value - both have an equality bail-out - so an application + // that re-sets the same bindings every frame emits nothing, which is what the pipeline + // composite's uniform mirror needs (it replays every override per draw). + Uint64 EmitProgramBindings(const ProgramObject& program, MGPipeHandle cso) { + if (MGPipeHandleIsNull(cso)) return 0; + const Uint32 backendStateVersion = program.GetBackendStateVersion(); + const Uint32 blockBindingVersion = program.GetBlockBindingVersion(); + Latch& latch = LatchFor(cso); + if (latch.BindingsLive && latch.BindingsGen == cso.Gen && + latch.BindingsBackendStateVersion == backendStateVersion && + latch.BindingsBlockBindingVersion == blockBindingVersion) { + return 0; + } + + m_blockBindings.clear(); + m_samplerUnits.clear(); + m_storageOverrides.clear(); + m_storageOverrideNames.clear(); + + // TAIL 1: dense, in BLOCK index order - the space GetActiveUniformBlocksCount(), + // GetUniformBlockName(i) and GetUniformBlockBinding(i) all speak, which is exactly + // the space the server's CacheResourceLocations walks. Dense because the server + // indexes it by i and a sparse form would need a second index space on the wire. + const Int blockCount = program.GetActiveUniformBlocksCount(); + if (static_cast(std::max(blockCount, 0)) > kMGPipeMaxProgramBlockBindings) { + // A COUNTED REFUSAL AND NOT A TRUNCATION (D-J3): a program with more uniform + // blocks than the record can name would have the tail of its block set bound + // to whatever the archive's link-time snapshot said, silently. The counter is + // what makes that visible; the record is not emitted at all, so the server + // keeps the archive's values for every block rather than for some of them. + ++m_bindingRefusals; + return 0; + } + m_blockBindings.reserve(static_cast(std::max(blockCount, 0))); + for (Int i = 0; i < blockCount; ++i) { + m_blockBindings.push_back(static_cast(program.GetUniformBlockBinding(static_cast(i)))); + } + + // TAIL 2: sparse, ascending by LOCATION, because the frontend's array is indexed by + // uniform location and maxUniformLocation runs into the thousands while the + // sampler count is a handful. -1 is "never assigned" and is what the server's own + // default already is, so it is not worth a wire entry. + const Uint maxUniformLoc = program.GetMaxUniformLocation(); + for (Uint loc = 0; loc <= maxUniformLoc; ++loc) { + // The same guard CacheResourceLocations uses one level down: a location with no + // uniform behind it has an empty name and must not index the type tables. + if (program.GetUniformName(loc).empty()) continue; + const Int unit = program.GetUniformSamplerOrImageUnitIndex(loc); + if (unit < 0) continue; + if (m_samplerUnits.size() >= kMGPipeMaxProgramSamplerUnits) { + ++m_bindingRefusals; + return 0; + } + MGPProgramSamplerUnit entry{}; + entry.Location = loc; + entry.Unit = static_cast(unit); + m_samplerUnits.push_back(entry); + } + + // TAIL 3: name-keyed BY DESIGN. A shader storage block has three index spaces - the + // frontend interface query, DirectVulkan's descriptor order and the real driver's - + // and the name is the only coordinate all three agree on (ProgramObject.h says so + // over the setter). Empty for the overwhelming majority of programs, which is why + // the signature below exists: the server compares one Uint64 and touches the names + // only on a rebuild. + const auto& overrides = program.GetShaderStorageBlockBindingOverrides(); + if (overrides.size() > kMGPipeMaxProgramStorageOverrides) { + ++m_bindingRefusals; + return 0; + } + Uint64 signature = 0; + m_storageOverrides.reserve(overrides.size()); + m_storageOverrideNames.reserve(overrides.size()); + for (const auto& [blockName, binding] : overrides) { + if (binding < 0) continue; // never rebound; the declared qualifier still stands + MGPProgramStorageOverride entry{}; + entry.Binding = binding; + m_storageOverrides.push_back(entry); + m_storageOverrideNames.push_back(blockName); + signature += MGPipeStorageOverrideSignatureEntry(blockName, binding); + } + m_storageOverrides.resize(m_storageOverrideNames.size()); + + m_lastBindings = MGPProgramBindings{}; + m_lastBindings.Cso = cso; + m_lastBindings.Signature = signature; + m_lastBindings.BlockBindingCount = static_cast(m_blockBindings.size()); + m_lastBindings.SamplerUnitCount = static_cast(m_samplerUnits.size()); + m_lastBindings.StorageOverrideCount = static_cast(m_storageOverrides.size()); + + // The name POINTERS, index-aligned with the override tail. Built here rather than + // inside the loop because push_back on m_storageOverrideNames may reallocate and + // every c_str() taken before that would dangle. + m_storageOverrideNamePtrs.clear(); + m_storageOverrideNamePtrs.reserve(m_storageOverrideNames.size()); + for (const String& name : m_storageOverrideNames) m_storageOverrideNamePtrs.push_back(name.c_str()); + + MGPipeRouteSetProgramBindings( + m_lastBindings, m_blockBindings.empty() ? nullptr : m_blockBindings.data(), + m_samplerUnits.empty() ? nullptr : m_samplerUnits.data(), + m_storageOverrides.empty() ? nullptr : m_storageOverrides.data(), + m_storageOverrideNamePtrs.empty() ? nullptr : m_storageOverrideNamePtrs.data()); + ++m_bindingSets; + + latch.BindingsLive = true; + latch.BindingsGen = cso.Gen; + latch.BindingsBackendStateVersion = backendStateVersion; + latch.BindingsBlockBindingVersion = blockBindingVersion; + return sizeof(MGPProgramBindings) + m_blockBindings.size() * sizeof(Int32) + + m_samplerUnits.size() * sizeof(MGPProgramSamplerUnit) + + m_storageOverrides.size() * sizeof(MGPProgramStorageOverride); + } + + // 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(program.GetUBOSize()); + m_lastDesc.ReservedNumSamplesOffset = static_cast(spirv.reservedNumSamplesOffset); + m_lastDesc.SpirvStatus = spirv.spirvStatus ? 1 : 0; + // P5e (pg), ID-88 / ruling 9: THE FAILED RELINK OF A BOUND PROGRAM, and the ruling + // asked this package to verify the frontend's actual behaviour rather than assume + // it. VERIFIED, at ProgramObject.cpp:510-531: `Link()`'s PROLOGUE bumps + // m_linkVersion and then assigns `m_artifacts = {}` - "the complete not-linked + // state" in its own words - before the link body runs, and every failure arm of + // ProgramLinkTask leaves `linkStatus = false`. So a failed relink reports UNLINKED + // through GetLinkStatus(), the re-issue this latch triggers is the one the ruling + // names, and it carries LinkStatus = 0. + // + // AND NEVER AN object_death: the program object is alive, its handle is alive, and + // its twin must stay alive to be rebuilt by the next successful link. What the + // server does with LinkStatus = 0 is exactly what the monolith arm already does + // with GetLinkStatus() == false - glUseProgram(0), a visible no-op draw - which is + // also what GL requires, since MobileGL's frontend withdraws LINK_STATUS here. + m_lastDesc.LinkStatus = program.GetLinkStatus() ? 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(spirv.generatedSpirv[i].data()); + m_lastDesc.Spirv[i].Size = 0; + } + m_lastDesc.Reflection.Seg = kMGHostSpanSegNone; + m_lastDesc.Reflection.Offset = reinterpret_cast(&link); + m_lastDesc.Reflection.Size = 0; + + // P5e (pg): THE STAGE OF EACH MODULE, beside the modules and not folded into + // StageMask. StageMask is a bit SET and GL lets two shader objects of one stage be + // attached to a program, so a list rebuilt from it can be SHORTER than + // generatedSpirv - and the server pairs the two by one running index. The client + // arm frames this in front of the archive; the monolith arm discards it and reads + // the snapshot off the object it is handed. Taken from the same accessor + // MGPipeStageMaskOf walks, so the mask and the list cannot disagree. + m_linkedStageWords.clear(); + for (const ShaderStage stage : program.GetLinkedShaderStages()) { + m_linkedStageWords.push_back(static_cast(stage)); + } + + MGPipeRouteCreateShaderState(m_lastDesc, &link, &spirv, m_linkedStageWords.data(), + static_cast(m_linkedStageWords.size())); + // 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; + } + + // P5e (pg): THE BINDINGS LATCH GOES WITH THE GLOBAL-CONSTANTS ONE, and for the same + // reason stated one branch up. The applier clears all three binding tails on a + // re-issued create (the indices point into an archive that has just been replaced), + // so a latch that survived it would suppress the very record that has to re-establish + // them and the server would draw the whole program off the archive's link-time + // snapshot. Invalidated rather than emitted here: EmitShaderState sends the bindings + // immediately after this returns, which is also the ONLY order that works. + latch.BindingsLive = false; + 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; + const MGPipeHandle cso = AcquireShaderCso(program, bytes); + // P5e (pg): the birth hook publishes the bindings too, for the same reason the + // validate point does. A program born here and drawn before the next validate point + // would otherwise reach the server with its archive's LINK-TIME snapshot and no + // delta, which is right only for a program nothing has rebound since - and this + // hook is exactly where a program that WAS rebound at creation time arrives. + EmitProgramBindings(program, cso); + } + + // 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& table = TableOf(handle); + const SizeT slot = SlotIndexOf(handle); + if (slot >= table.size()) return false; + const Latch& latch = table[slot]; + return latch.RecordLive && latch.RecordGen == handle.Gen; + } + + // The memo's other half, and the bound-mirror clearing beside it. + // + // THE CALLER IS THE CONTRACT's DEATH HELPER (P4a final review C-2): the death path + // reads the contract's latch for the wire delete and then forwards here, before the + // slot is freed, so a dead handle no longer reads as published in this memo between + // the death and the recycle and the three bound mirrors never name a dead program. + // Gen-keyed, so a late notice for a slot already handed out again clears nothing of + // the successor's. + void NoteRecordDestroyed(MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return; + Vector& 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; + m_bindingSets = m_bindingRefusals = 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; } + // P5e (pg). The record a case reads back, and the two counters D-J3's rule asks for: + // how many set_program_bindings went out, and how many programs were REFUSED because + // one of their three sets is larger than the record can name. A refusal leaves the + // server on the archive's link-time snapshot for that program - correct for anything + // that was never rebound after the link, wrong for anything that was, and countable + // either way, which is the whole point of not truncating. + const MGPProgramBindings& LastProgramBindings() const { return m_lastBindings; } + Uint64 ProgramBindingsSetCount() const { return m_bindingSets; } + Uint64 ProgramBindingsRefusalCount() const { return m_bindingRefusals; } + + 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; + // P5e (pg): set_program_bindings' own key, in the SAME latch because it is keyed on + // the same handle and a slot recycle has to invalidate both at once. Kept separate + // from RecordLive because the two records have different triggers - a create fires + // on a link-version move, the bindings fire on a binding move without a relink, and + // most frames have neither. + Bool BindingsLive = false; + Uint32 BindingsGen = 0; + Uint32 BindingsBackendStateVersion = 0; + Uint32 BindingsBlockBindingVersion = 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& TableOf(MGPipeHandle handle) { + return MGPipeIsCompositeShaderSlot(handle.Slot) ? m_compositeLatch : m_latch; + } + const Vector& TableOf(MGPipeHandle handle) const { + return MGPipeIsCompositeShaderSlot(handle.Slot) ? m_compositeLatch : m_latch; + } + static SizeT SlotIndexOf(MGPipeHandle handle) { + return MGPipeIsCompositeShaderSlot(handle.Slot) + ? static_cast(handle.Slot - kMGPipeShaderCsoCompositeSlotBase) + : static_cast(handle.Slot); + } + Latch& LatchFor(MGPipeHandle handle) { + Vector& 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(MGPipeKind::ShaderCso); + return only; + } + + MGPProgramDesc m_lastDesc{}; + MGPGlobalConstants m_lastConstants{}; + // P5e (pg): the three tails and their names, MEMBERS rather than locals so the steady + // frame allocates nothing - the same reason every other emitter in this directory keeps + // its scratch. Cleared and refilled per emission; none of them outlives the route call + // except as the record's own copy on the far side (rule C). + MGPProgramBindings m_lastBindings{}; + Vector m_blockBindings; + Vector m_samplerUnits; + Vector m_storageOverrides; + Vector m_storageOverrideNames; + Vector m_storageOverrideNamePtrs; + // The stage of each module of the descriptor being built, index-aligned with + // GetGeneratedSpirv(); the client arm frames it in front of the archive. + Vector m_linkedStageWords; + + Vector m_latch; + Vector 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; + Uint64 m_bindingSets = 0; + Uint64 m_bindingRefusals = 0; + }; + + inline MGPipeProgramEmitter& MGPipeProgramEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason; heap-constructed and + // intentionally leaked at exit, and it MUST NOT hold a frontend SharedPtr - that is + // the exit-order rule, stated over every MGPipe process singleton rather than over the + // ones a destructor reaches today. + static MGPipeProgramEmitter* emitter = new MGPipeProgramEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/ResourceTracker.h b/MobileGL/MG_Impl/Pipe/ResourceTracker.h new file mode 100644 index 000000000..d6870ea15 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/ResourceTracker.h @@ -0,0 +1,745 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/ResourceTracker.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The CLIENT side of P3a's resource family (brief D-A, D-B, D-C, D-D). +// +// WHERE IT RUNS, and it is the ONE exception to push-at-validate (ARCHITECTURE.md 5.1): +// the seven BufferBackendOps hooks already dispatch at the GL call that causes them, so +// their pipe calls are emitted from the same BufferObject dispatchers - not from +// MGPipeValidateForVerb. Nothing about buffers moves to validate time in P3a. +// +// WHAT LIVES HERE +// * the sticky BindMask, one constexpr BufferTarget -> bit table with a static_assert +// that it covers every enumerator, so a new target cannot be silently unmapped; +// * the lifetimeId -> {slot, gen} mint (through MGPipeSlots(), the one allocator) and +// the slot -> BufferObject* INVERSE the reverse channel resolves a writeback through; +// * the nine MGPipeEmitResource* bodies, declared in MG_Pipe/PipeMutation.h so that +// MG_State sees a declaration and never this file (the same layering PipeMutation.h +// already has for MGP_NOTE_MUTATION: declare in MG_Pipe, define in MG_Impl); +// * the MGPSubData range splitter, because one record's box caps the destination at a +// 2^31-1 offset and a 2^32-1 size; +// * the map-persistent-roundtrips counting site. +// +// HEADER-ONLY, for the ownership reason Tracker.h states in full: the root CMakeLists.txt +// that would name a new .cpp belongs to the contract package and is frozen behind the tag. +// MG_Impl/Pipe/PipeFill.cpp is the one translation unit that includes it in the library. +// +// NO TIMER, and no per-call record copy on a HOT path. The two observables a unit case +// needs - the last emitted descriptor and the per-call counts - are written only by +// resource_create and resource_respecify, which run once per glBufferData rather than per +// upload; resource_subdata, the hot one, is observed through the pure builders below +// instead (MGPipeBuildSubDataRecord / MGPipeForEachSubDataRecordRange), which is also what +// lets a test drive the splitter at both of its bounds without a 4 GiB buffer. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include + +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +// P5c ev (CONTRACT-P5C §4.3): the writeback consumer's SEG_EVENT arm resolves the blobref +// through the client session's own SegmentTable, and the transport check below is the same +// MG_Config::Transport probe PipeFill.cpp uses. Behind the build option for G1's reason - +// nothing under MG_Remote may be reachable from a pull build. +#include +#endif + +#include +#include + +namespace MobileGL::MG_Pipe { + + // --------------------------------------------------------------------------------- + // D-A3: BindMask + // --------------------------------------------------------------------------------- + + // MGPResourceDesc::BindMask's twelve bits MOVED TO MG_Pipe/MGPipeTypes.h AT P4a, beside + // the field, exactly as the note that stood here said they would when a second producer + // appeared: P4a's texture family sets kMGPipeBindSampler / kMGPipeBindShaderImage / + // kMGPipeBindRenderTarget / kMGPipeBindDepthStencil, the four bits nothing set before. + // No alias is written for them because none is possible or needed - both files are + // namespace MobileGL::MG_Pipe and this one includes that header, so every spelling below + // and in package B's code is unchanged. + // + // What stays here is the BUFFER half of the mapping, which is this file's own: the + // BufferTarget table, its sentinel and its completeness assert. + + // A sentinel the table below returns for an enumerator it does not name. It is NOT a + // legal mask value: every enumerator must be listed, including the ones that map to no + // bit at all, so that ADDING a BufferTarget is a build break here rather than a bit + // that silently stops being published. + inline constexpr Uint32 kMGPipeBindUnmapped = 0x10000u; + + // The one table. No `default:` arm on purpose - that is what makes the static_assert + // below able to see an unnamed enumerator. + constexpr Uint32 MGPipeBindMaskForBufferTarget(BufferTarget target) { + switch (target) { + case BufferTarget::Vertex: + return kMGPipeBindVertex; + // GL_ELEMENT_ARRAY_BUFFER is the VAO's element slot: the same bind is both "this + // resource is an index buffer" and "the server may need its bytes on its own side". + case BufferTarget::Index: + return kMGPipeBindIndex | kMGPipeBindElementArray; + case BufferTarget::Uniform: + return kMGPipeBindConstant; + case BufferTarget::ShaderStorage: + return kMGPipeBindShaderBuffer; + case BufferTarget::DispatchIndirect: + case BufferTarget::DrawIndirect: + case BufferTarget::Parameter: + return kMGPipeBindIndirect; + // A texture buffer's backing store is SAMPLED through the texture that names it. + case BufferTarget::Texture: + return kMGPipeBindSampler; + case BufferTarget::TransformFeedback: + return kMGPipeBindStreamOutput; + case BufferTarget::AtomicCounter: + return kMGPipeBindAtomic; + // TRANSFER AND QUERY TARGETS, which the bind mask deliberately does not name: none + // of them is a pipeline binding, none of them makes the server keep anything, and + // a bit set for them would only widen what a split server mirrors. Listed rather + // than defaulted, so the completeness assert still sees them. + case BufferTarget::CopyRead: + case BufferTarget::CopyWrite: + case BufferTarget::PixelPack: + case BufferTarget::PixelUnpack: + case BufferTarget::Query: + return kMGPipeBindNone; + case BufferTarget::BufferTargetCount: + case BufferTarget::Unknown: + return kMGPipeBindNone; + } + return kMGPipeBindUnmapped; + } + + constexpr Bool MGPipeEveryBufferTargetIsMapped() { + for (SizeT i = 0; i < static_cast(BufferTarget::BufferTargetCount); ++i) { + if (MGPipeBindMaskForBufferTarget(static_cast(i)) == kMGPipeBindUnmapped) { + return false; + } + } + return true; + } + static_assert(MGPipeEveryBufferTargetIsMapped(), + "a BufferTarget enumerator has no MGPResourceDesc::BindMask row: add it to " + "MGPipeBindMaskForBufferTarget, including a deliberate kMGPipeBindNone, or the " + "resource it is bound to stops publishing that binding (D-A3, P8 expectation 1)"); + static_assert(MGPipeBindMaskForBufferTarget(BufferTarget::Index) & kMGPipeBindElementArray, + "the ELEMENT_ARRAY bit is the index host mirror's switch (ARCHITECTURE.md 10.3)"); + + // --------------------------------------------------------------------------------- + // The discriminators MGPResourceDesc / MGPSubData carry for a BUFFER + // --------------------------------------------------------------------------------- + // + // P4a MINTED THE FIRST LIST: MGPipeTypes.h now carries enum MGPipeResourceTarget beside + // the field, and kMGPipeResourceTargetBuffer moved there with it - the narrowed + // resource_respecify ack predicate lives in that header and has to name the buffer target + // explicitly, and it may not reach into MG_Impl to do so. The second discriminator is the + // frontend enum, named rather than open-coded, and stays here because only this file + // produces it. + inline constexpr Uint8 kMGPipeResourceStorageKindBuffer = + static_cast(MobileGL::TextureStorageType::Buffer); + + // --------------------------------------------------------------------------------- + // D-A2: the payload builders. Pure, so a unit case can assert field by field. + // --------------------------------------------------------------------------------- + + // The descriptor for `buffer`. `storageDefined` is false for the create that the + // constructor emits - storage is defined lazily by the first respecify and a backend + // tolerates a resource that has none - and true for every respecify. + inline MGPResourceDesc MGPipeBuildResourceDesc(const MG_State::GLState::BufferObject& buffer, + MGPipeHandle handle, Uint16 bindMask, + Bool storageDefined) { + MGPResourceDesc desc{}; + desc.Resource = handle; + desc.Target = static_cast(kMGPipeResourceTargetBuffer); + desc.StorageKind = kMGPipeResourceStorageKindBuffer; + desc.BindMask = bindMask; + if (storageDefined) { + // MGPResourceDesc::Width is a Uint32 and that is the CONTRACT's shape, not this + // package's, so a store of 4 GiB or more cannot be declared at all. Truncating it + // silently is the one answer that must not happen: the applier's range gate would + // then refuse the first legal write past the truncated extent as + // Fatal{ProtocolCorruption} and name a corruption that is really a narrowing here. + // So it is said out loud, once, in every build - the assertion compiles out at + // INFO, which is what all three gate builds are. + if (buffer.GetSize() > static_cast(0xFFFFFFFFull)) { + MGLOG_E_ONCE("MGPipe: buffer %u declares a store of %llu bytes, which does not fit " + "MGPResourceDesc::Width - the descriptor's extent is narrowed and every " + "write past 4 GiB will be refused by the applier's range gate", + buffer.GetExternalIndex(), + static_cast(buffer.GetSize())); + MOBILEGL_ASSERT(false, "MGPResourceDesc::Width cannot carry this buffer's size"); + } + desc.Width = static_cast(buffer.GetSize()); + desc.Usage = static_cast(buffer.GetUsage()); + desc.StorageFlags = static_cast(buffer.GetStorageFlags()); + desc.Immutable = buffer.IsImmutableStorage() ? 1 : 0; + desc.HasDefinedContent = buffer.HasDefinedContent() ? 1 : 0; + } + // Diagnostics only: a GL name is never an identity, never a memo key and never part + // of a content hash (ARCHITECTURE.md 4.2.1). + desc.GlNameForDiag = static_cast(buffer.GetExternalIndex()); + return desc; + } + + // The buffer half of MGPSubData: the destination range rides in the box's first + // coordinate and first extent, and MGPipeSetSubDataBufferRange is the ONLY spelling of + // that convention. Returns false, with the record untouched, when the range does not fit + // one record - which is where MGPipeForEachSubDataRecordRange comes in. + // + // `sourceIsVerbatimLevelShadow` is the record's own question - "are these bytes an + // untransformed level shadow?" - and it is a PARAMETER because the answer differs by + // caller: resource_subdata hands over the client's own shadow at an offset into it and + // says yes; buffer_subdata_resident hands over the application's staging store, or the + // locally expanded pattern FillSubData built, and both say no. Nothing reads it on the + // buffer path today, which is exactly why it must not be a hard-coded 1 that becomes + // wrong the moment something does. + // + // Blob is FILLED, exactly: Seg is kMGHostSpanSegNone (monolith - the bytes travel beside + // the record through the entry point's companion pointer) and Size is the piece's own + // byte length, which is what the applier's ONE Blob rule holds a non-zero declaration to + // (PipeApply.cpp's SubDataBoxFault: != 0 && != MGPipeSubDataBufferSize is refused). + // Leaving it 0 would be legal too; declaring it correctly is the stronger of the two. + inline Bool MGPipeBuildSubDataRecord(MGPipeHandle res, Uint64 offset, Uint64 size, MGPSubData& out, + Bool sourceIsVerbatimLevelShadow) { + out = MGPSubData{}; + out.Res = res; + out.Target = kMGPipeResourceTargetBuffer; + out.SourceIsVerbatimLevelShadow = sourceIsVerbatimLevelShadow ? 1 : 0; + if (!MGPipeSetSubDataBufferRange(out, offset, size)) return false; + out.Blob.Seg = kMGHostSpanSegNone; + out.Blob.Size = size; + return true; + } + + // ONE record's destination box caps the offset at 2^31-1 and the size at 2^32-1 + // (MGPipeTypes.h), so a range beyond either has to be split. The pieces are CONTIGUOUS + // and in ASCENDING order, and both properties are load-bearing rather than tidy: + // splitting a content write into overlapping or reordered pieces would change what the + // backend's queue-and-drain sees, and the Mali WAR-stall fix depends on that queue being + // exactly the writes the application made. + inline constexpr Uint64 kMGPipeSubDataMaxRecordOffset = 0x7FFFFFFFull; + inline constexpr Uint64 kMGPipeSubDataMaxRecordSize = 0xFFFFFFFFull; + + // WITH THE RECORD'S OWN BOUND THE SPLIT IS NOT REACHABLE, and saying so is better than a + // loop that reads as if it were: a second piece starts at least 2^32-1 bytes past the + // first, which is already past the OFFSET cap, so a range too big for one record is + // REFUSED rather than split. The offset cap cannot be split away at all - every piece of + // a range that starts past 2^31-1 starts past it too - and a silent truncation is the one + // answer that must not happen, so the walk emits nothing and its caller says so once. + // + // `maxChunk` exists because the record's bound is not the tight one for long: a transport + // segment is far smaller (tens of MiB), and that is where this walk starts producing real + // splits. It is a parameter now, and exercised at a reachable value by the unit gate, so + // that lowering it is one argument rather than a new code path written under pressure. + template + inline Bool MGPipeForEachSubDataRecordRange(Uint64 offset, Uint64 size, Fn&& piece, + Uint64 maxChunk = kMGPipeSubDataMaxRecordSize) { + if (offset > kMGPipeSubDataMaxRecordOffset) return false; + if (size == 0) return true; + if (maxChunk == 0) return false; + // Every piece has to be encodable BEFORE any of them is emitted: a half-emitted range + // is a partial content write the backend would land as if it were the whole one. + const Uint64 chunkCap = maxChunk < kMGPipeSubDataMaxRecordSize ? maxChunk : kMGPipeSubDataMaxRecordSize; + for (Uint64 at = offset; at < offset + size; at += chunkCap) { + if (at > kMGPipeSubDataMaxRecordOffset) return false; + } + for (Uint64 at = offset, left = size; left > 0;) { + const Uint64 chunk = left > chunkCap ? chunkCap : left; + piece(at, chunk); + at += chunk; + left -= chunk; + } + return true; + } + + // --------------------------------------------------------------------------------- + // The tracker: handles, the inverse, the sticky mask, the reverse channel + // --------------------------------------------------------------------------------- + + class MGPipeResourceTracker { + public: + using BufferObject = MG_State::GLState::BufferObject; + using GLContext = MG_State::GLState::GLContext; + + // The handle for `buffer`, minted on first use. Minting is NOT gated on a backend + // having registered MGPipeResourceOps: the handle is CLIENT state and + // set_vertex_buffers names it whether or not the resource family is switched on, so + // gating it would make the vertex-input subsystem emit null handles whenever the + // resource subsystem is off. Only the CALLS are gated (D-A1). + MGPipeHandle Acquire(BufferObject& buffer) { + const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Buffer, buffer.GetLifetimeId()); + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1); + m_bySlot[slot].Object = &buffer; + m_bySlot[slot].Gen = handle.Gen; + return handle; + } + + // The handle a buffer already has, or the null handle. Never mints - the emission + // path calls Acquire, the query paths call this. + MGPipeHandle Find(const BufferObject& buffer) const { + return MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, buffer.GetLifetimeId()); + } + + // D-D's inverse, and a RAW pointer is exact here: the entry exists only between the + // create the constructor emits and the destroy the destructor emits, and a readback + // is only ever issued for a live, bound buffer. A WeakPtr would be wrong - the + // object does not own itself through a SharedPtr at those two moments. The Gen + // compare is what refuses a stale handle rather than resolving it to whatever now + // occupies the slot. + BufferObject* Resolve(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_bySlot.size()) return nullptr; + const Entry& entry = m_bySlot[slot]; + if (entry.Object == nullptr || entry.Gen != handle.Gen) return nullptr; + if (MGPipeSlots().GenOfSlot(MGPipeKind::Buffer, handle.Slot) != handle.Gen) return nullptr; + return entry.Object; + } + + // Drops the inverse entry and the sticky mask. The CALLER frees the slot afterwards, + // in that order (D-L): MGPipeSlotAllocator::Free erases the lifetimeId -> slot + // mapping, so anything that has to resolve the handle must do it first. + void Retire(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) return; + m_bySlot[slot] = Entry{}; + } + + // ---- D-L: was resource_create actually EMITTED for this slot? ---- + // + // The create is gated at its call site (BufferObject's constructor) and the destroy + // is gated inside MGPipeEmitResourceDestroyAndFree, so the two ask the SAME question + // at two different moments. A buffer constructed while a backend's table was + // registered and destroyed after UnregisterBufferBackendOps() would take the second + // answer, free its slot, and leave the applier's record Live - on a slot the + // allocator is about to hand out again, with the backend's twin (a driver buffer id) + // still attached to it. So the answer is LATCHED at the create and the destroy uses + // the latched one; the two are then a pair by construction rather than by the + // registration outliving every buffer. + void NotePublished(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) return; + m_bySlot[slot].Published = true; + } + Bool WasPublished(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + return slot < m_bySlot.size() && m_bySlot[slot].Published; + } + + // The sticky everBoundAs mask. Sticky exactly as MGPResourceDesc::ImageBindableHint's + // everImageBound is: ORed, never cleared, so a buffer that was an element array once + // keeps saying so. + Uint16 BindMask(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + return slot < m_bySlot.size() ? m_bySlot[slot].BindMask : Uint16{0}; + } + + // OR one target's bit into a handle's sticky mask, without looking at the context at + // all. This is what closes the sampling window for the two bits anything keys on: + // the vertex-input emitters resolve, at EVERY draw, exactly the attribute buffers and + // the element-slot buffer, so any buffer ever DRAWN FROM carries its ARRAY_BUFFER / + // ELEMENT_ARRAY bit for the rest of its life whether or not it happened to be bound + // at a storage op. It grows the table rather than dropping the note: it is called + // from the validate point, which is GL-thread by construction, and a slot outside the + // table is a buffer whose mint this process has not seen (a unit fixture's + // ResetForTest, in practice). + void NoteBoundAs(MGPipeHandle handle, BufferTarget target) { + if (MGPipeHandleIsNull(handle)) return; + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) m_bySlot.resize(slot + 1); + m_bySlot[slot].BindMask |= static_cast(MGPipeBindMaskForBufferTarget(target)); + } + + // Accumulates into the sticky mask every target `buffer` is bound to RIGHT NOW, and + // returns the accumulated value. + // + // [DEVIATION, recorded in client-v2.md] D-A3 asks for the OR at every glBindBuffer / + // glBindBufferBase / glBindBufferRange / VAO element-slot bind, and C.1 points at + // MG_State/GLState/BufferState/BufferState.{h,cpp} for it - a file this package DOES + // own. The brief is wrong about where the entry points are: BufferState only VENDS + // BindingSlot& / BindingSlotRange1D&, and the .Bind() calls are + // MG_Impl/GLImpl/Buffer/GL_Buffer.cpp's (BindBuffer_State, BindBufferBase_State, + // BindBufferRange_State), which C.5 assigns to no package. So the mask is accumulated + // by SAMPLING the frontend's live binding state instead - here, at every create and + // respecify, which is where the value is PUBLISHED - and ORed into a per-slot sticky + // field that is never cleared. + // + // WHAT SAMPLING ALONE CANNOT SEE is not "a bind after the last respecify" (which the + // specified design misses too) but a TRANSIENT bind: bind an EBO, draw, unbind, then + // define it through DSA - the respecify's sample sees no binding at all, and the DSA + // idiom makes that the common case rather than a corner (TryAdoptLargeStorage's own + // comment names glNamedBufferSubData as what MC 26.3 streams with). That hole is + // closed for the two bits anything keys on by NoteBoundAs above, called from + // EmitVertexBuffers / EmitIndexBuffer at every draw. What is left unpublished is a + // buffer that is bound, never drawn from, and never re-specified afterwards; the + // remaining fix is one line in each of GL_Buffer.cpp's three *_State binders, for the + // seven bits nothing keys on yet, and it stays handed to whoever owns that file. + // + // The scan is skipped unless a binding-slot version moved since the last one, which + // is one Uint16 load per global target and none per binding point. It is NOT called + // from the content emitters, deliberately: it walks the whole context's binding state + // and writes the tracker, and one of those emitters (resource_subdata) is on the path + // D-A2 preserves as reachable off the render thread. Extra sampling could only widen + // a sticky union, but not at the price of a context-wide read from the wrong thread. + Uint16 RefreshBindMask(GLContext& ctx, const BufferObject& buffer, MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (slot >= m_bySlot.size()) return 0; + Entry& entry = m_bySlot[slot]; + const Uint64 epoch = BindEpoch(ctx); + if (epoch == m_bindEpoch && entry.BindMaskEpoch == epoch) return entry.BindMask; + m_bindEpoch = epoch; + entry.BindMaskEpoch = epoch; + Uint16 mask = entry.BindMask; + for (const auto target : MG_State::GLState::GlobalBufferTargets) { + if (ctx.GetBufferBindingSlot(target).GetBoundObject().get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(target)); + } + } + for (const auto target : MG_State::GLState::BufferBindPointTargets) { + const SizeT touched = ctx.GetTouchedBufferBindingPointCount(target); + for (SizeT i = 0; i < touched; ++i) { + if (ctx.GetBufferBindingPoint(target, static_cast(i)).GetBoundObject().get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(target)); + break; + } + } + } + // The index slot is the BOUND VAO's, not BufferState's, so it is not in + // GlobalBufferTargets and GetBufferBindingSlot(Index) asserts without a VAO. + if (const auto& vao = ctx.GetBoundVertexArray()) { + if (vao->GetIndexBufferBindingSlot().GetBoundObject().get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(BufferTarget::Index)); + } + for (int i = 0; i < MG_State::GLState::VertexArrayObject::MAX_VERTEX_ATTRIBS; ++i) { + if (vao->GetAttribute(static_cast(i)).Buffer.get() == &buffer) { + mask |= static_cast(MGPipeBindMaskForBufferTarget(BufferTarget::Vertex)); + break; + } + } + } + entry.BindMask = mask; + return mask; + } + + // ---- the two observables a unit case reads (see the header comment) ---- + const MGPResourceDesc& LastDesc() const { return m_lastDesc; } + Uint64 CreateCount() const { return m_creates; } + Uint64 RespecifyCount() const { return m_respecifies; } + Uint64 DestroyCount() const { return m_destroys; } + Uint64 MapPersistentCount() const { return m_mapPersistents; } + + void NoteDesc(const MGPResourceDesc& desc, Bool isCreate) { + m_lastDesc = desc; + if (isCreate) { + ++m_creates; + } else { + ++m_respecifies; + } + } + void NoteDestroy() { ++m_destroys; } + void NoteMapPersistent() { ++m_mapPersistents; } + + // A unit fixture's per-case reset, and the library never calls it. THE RULE, stated + // rather than left as an absence, because "nothing resets this" is not a reason: + // + // A buffer handle and the applier record it names are SHARE-GROUP OBJECT STATE. + // A GL object lives in a share group, not in a context, so a make-current changes + // neither. The applier's MGPipeApplierReset() is a make-current and deliberately + // keeps its Resources / VertexElementsCsos (PipeApply.h says so beside them); the + // ONLY things that drop a record are the object's own death signal - + // resource_destroy, which ~BufferObject raises through + // MGPipeEmitResourceDestroyAndFree, and delete_vertex_elements - and + // MGPipeApplierReleaseObjectRecords(), which is the SERVED CONTEXT's teardown and + // is deliberately wired to nothing in the monolith (there is one applier behind + // every context, so calling it on one context's destruction would drop every other + // context's records). + // + // So this tracker needs no re-publication path on a fresh context and must not have + // one: re-emitting resource_create for a record the applier still holds would move + // its Serial for nothing. What the client owes instead is the destroy - which + // ~BufferObject already emits, in the fixed emit-then-free order (D-L) - and that is + // the whole of the client's side of the record lifecycle. + // + // The vertex-input emitter's latches are the OTHER half and are genuinely per + // context: MGPipeVertexInputEmitter::Reset() is called from the FreshlyPrimed arm + // because the applier's vertex-input WORKING state (the bound handle, the window, the + // fetch shift) IS cleared there. Its vertex-elements RECORDS are not, which is why + // the emitter's Reset drops the "already published" latches but no create is lost: + // the latch is what says "re-publish", and re-publishing an unchanged configuration + // is a bounded over-fire, not a dropped write. + void ResetForTest() { + m_bySlot.clear(); + m_bindEpoch = 0; + m_lastDesc = MGPResourceDesc{}; + m_creates = m_respecifies = m_destroys = m_mapPersistents = 0; + } + + private: + struct Entry { + BufferObject* Object = nullptr; + Uint32 Gen = 0; + Uint16 BindMask = 0; + Bool Published = false; + Uint64 BindMaskEpoch = 0; + }; + + // "Has any buffer binding moved since the last scan": the sum of the binding-slot + // versions, which BindingSlot bumps only on a real change. A collision costs one + // skipped rescan of ONE buffer's mask, and the mask is re-scanned at the next + // emission whose epoch differs, so it can delay a bit by one storage op and never + // drop one - the same over-fire-is-free / under-fire-is-fatal direction every + // shutter in Tracker.h takes. + // + // IT DOES NOT SEE THE 84x4 INDEXED BINDING POINTS, and that is sound only because + // BindBufferBase_State / BindBufferRange_State also bind the GENERIC slot for the + // same target (GL_Buffer.cpp:1531 says why), so an indexed bind always moves one of + // the versions summed here. If that ever stops being true, the CONSTANT / + // SHADER_BUFFER / ATOMIC / STREAM_OUTPUT bits start being missed silently and the + // repair is to fold GetTouchedBufferBindingPointCount into the epoch. + static Uint64 BindEpoch(GLContext& ctx) { + Uint64 epoch = 1; + for (const auto target : MG_State::GLState::GlobalBufferTargets) { + epoch += ctx.GetBufferBindingSlot(target).GetVersion(); + epoch *= 3; + } + if (const auto& vao = ctx.GetBoundVertexArray()) { + epoch += vao->GetIndexBufferBindingSlot().GetVersion(); + epoch = MGPipeMixShutterValue(epoch, vao->GetLifetimeId()); + epoch = MGPipeMixShutterValue(epoch, vao->GetConfigVersion()); + } + return epoch; + } + + // The same mix Tracker.h's composite shutters use. Spelled here rather than + // included so this header does not depend on the tracker. + static constexpr Uint64 MGPipeMixShutterValue(Uint64 accumulator, Uint64 value) { + accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2); + return accumulator; + } + + Vector m_bySlot; + Uint64 m_bindEpoch = 0; + MGPResourceDesc m_lastDesc{}; + Uint64 m_creates = 0; + Uint64 m_respecifies = 0; + Uint64 m_destroys = 0; + Uint64 m_mapPersistents = 0; + }; + + // The monolith's one resource tracker, beside the state tracker, the CSO cache and the + // set-hash suppressor. + inline MGPipeResourceTracker& MGPipeResourceTrackerInstance() { + // NEVER DESTROYED, for MGPipeSlots()' reason (SlotAllocator.cpp): ~BufferObject reads + // and writes this tracker, and the objects that own the last reference to a + // BufferObject outlive every function-local static. + static MGPipeResourceTracker* tracker = new MGPipeResourceTracker(); + return *tracker; + } + + // --------------------------------------------------------------------------------- + // D-D: the client's half of the reverse channel + // --------------------------------------------------------------------------------- + + // The backend produced the bytes of a readback and hands them back through the channel. + // The client resolves the handle to its own object and writes the shadow; the epoch bump + // stays SERVER-side and happens AFTER this returns, never before (ARCHITECTURE.md 7.4: + // the reverse channel needs the same ordering guarantee as the forward one). + // + // THE BLOBREF'S THREE ARMS (CONTRACT-P5C §4.3, replacing the P3a monolith guard that + // rejected every Seg != kMGHostSpanSegNone and would have dropped every writeback EVENT + // on arrival): + // Seg == kSegEvent -> the wire shape (CONTRACT-P5C §1's reverse-channel row): + // Offset is the byte offset of the inline payload inside + // SEG_EVENT, resolved through the client session's OWN + // SegmentTable, bounds-checked against the announced size; + // Seg == kMGHostSpanSegNone -> monolith only: Offset IS the backend's mapped address + // (MGPipeTypes.h says so in as many words). With an active + // transport this is rule B in the reverse direction and is + // Fatal{ProtocolCorruption, "OnBufferWriteback.Seg"}; + // anything else -> the same Fatal. + inline void MGPipeClientOnBufferWriteback(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes) { + auto* buffer = MGPipeResourceTrackerInstance().Resolve(res); + if (buffer == nullptr) { + MGLOG_E_ONCE("MGPipe: OnBufferWriteback for a handle {%u,%u} that resolves to no buffer", + res.Slot, res.Gen); + return; + } + void* bytePtr = nullptr; + if (bytes.Seg == kMGHostSpanSegNone) { +#if MOBILEGL_BUILD_DISAGGREGATED + if (MG_Config::Transport != MG_Config::TransportMode::Monolith) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Seg\"} - a " + "writeback blobref carried Seg = kMGHostSpanSegNone (a raw host " + "address) with an active transport. Rule B binds the reverse " + "direction exactly as it binds MGHostSpan: on the wire the blobref " + "names SEG_EVENT and an in-segment offset, never a host address"); + std::abort(); + } +#endif + bytePtr = reinterpret_cast(static_cast(bytes.Offset)); + } +#if MOBILEGL_BUILD_DISAGGREGATED + else if (bytes.Seg == MG_Remote::Wire::kSegEvent) { + // Size == 0 with a live resource is legal - a zero-length writeback + // (CONTRACT-P5C §1) - and SegmentTable::Resolve answers nullptr for a zero size, + // so only a non-empty run goes through the bounds-checked resolve. + if (bytes.Size != 0) { + auto* session = MG_Remote::Client::ClientSession::Active(); + const void* resolved = + session == nullptr + ? nullptr + : session->Segments().Resolve(MG_Remote::Wire::kSegEvent, bytes.Offset, + bytes.Size); + if (resolved == nullptr) { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Offset\"} - " + "the writeback blobref's {%llu + %llu} does not resolve inside the " + "session's SEG_EVENT segment", + static_cast(bytes.Offset), + static_cast(bytes.Size)); + std::abort(); + } + bytePtr = const_cast(resolved); + } + } else { + MGLOG_F("MGPipe: Fatal{ProtocolCorruption, \"OnBufferWriteback.Seg\"} - a writeback " + "blobref carried Seg %u, which is neither kMGHostSpanSegNone (monolith) nor " + "kSegEvent (the wire shape)", + bytes.Seg); + std::abort(); + } +#else + else { + // The pre-P5c guard's monolith spelling, kept for builds with no transport layer: + // a segment tag cannot legitimately arrive here and the write must not be made + // from a null pointer. + MGLOG_E_ONCE("MGPipe: OnBufferWriteback carried a transport segment (%u) in a build " + "with no transport; the writeback is dropped", + bytes.Seg); + return; + } +#endif + buffer->WritebackFromBackend(DataPtr{bytePtr, static_cast(bytes.Size)}, + static_cast(offset)); + } + + // A draw or dispatch wrote these ranges. ARCHITECTURE.md 7.1 calls this a NARROWING + // channel - the client builds a conservative pending set at its own emission points and + // the callback only ever removes from it - so P3a's implementation marks exactly what + // the three Espryt MarkGpuWritten sites mark today and the observable behaviour is + // unchanged. The narrowing itself is P8/P9's. + inline void MGPipeClientOnGpuWritten(MGPipeHandle res, Uint rangeCount, const MGPRange* ranges) { + // THE SHAPE IS A CONTRACT POINT, not a formality: the announcement is ONE range + // covering kMGPipeWholeBuffer, deliberately not ZERO ranges, because zero will mean + // "a fully narrowed set - nothing is dirty" at P8/P9. Marking the whole buffer + // written for a zero-range announcement would be the narrowing channel run backwards, + // so the shape is asserted here rather than assumed. + MOBILEGL_ASSERT(rangeCount == 1 && ranges != nullptr, + "OnGpuWritten {slot=%u, gen=%u}: P3a announces exactly one whole-buffer range, " + "not %u", + res.Slot, res.Gen, static_cast(rangeCount)); + (void)ranges; + if (rangeCount == 0) return; + auto* buffer = MGPipeResourceTrackerInstance().Resolve(res); + if (buffer == nullptr) { + // Loud, like its sibling above: a backend announcing a write against a handle + // this client cannot resolve is a dropped MarkGpuWritten, and a dropped + // MarkGpuWritten is a stale shadow read back as if it were current. + MGLOG_E_ONCE("MGPipe: OnGpuWritten for a handle {%u,%u} that resolves to no buffer", res.Slot, + res.Gen); + return; + } + buffer->MarkGpuWritten(); + } + + // MONOLITH ONLY (CONTRACT-P5C §4.1): with an active transport the three reverse entries + // of gMGPipeCallbacks are the SERVER session's producer callbacks and the client + // consumers are invoked BY NAME from DrainEventRing - the global table is a + // producer-side surface under split. The caller (MGPipeMintResourceHandle) gates on the + // resolved transport, exactly as PipeFill.cpp's other transport arms do. + // + // Installed over only an EMPTY or OUR OWN entry: writing over one somebody else claimed + // is Fatal{RoleViolation, "callback-double-install"} - the "never over an entry a + // backend already claimed" comment, made a check. Finding our own function is the + // idempotent repeat this helper runs once per buffer mint, not a second installation. + inline void MGPipeInstallClientResourceCallbacks() { + const auto install = [](auto& entry, auto* fn, const char* name) { + if (entry != nullptr && entry != fn) { + MGLOG_F("MGPipe: Fatal{RoleViolation, \"callback-double-install\"} - %s is " + "already claimed by a different function. With an active transport " + "the reverse entries are the server session's producers; the client " + "installs them under monolith only", + name); + std::abort(); + } + entry = fn; + }; + install(gMGPipeCallbacks.OnBufferWriteback, &MGPipeClientOnBufferWriteback, + "OnBufferWriteback"); + install(gMGPipeCallbacks.OnGpuWritten, &MGPipeClientOnGpuWritten, "OnGpuWritten"); + } + + // The GPU-write announcement for a buffer the backend holds only a FRONTEND POINTER to - + // Magma's barrier-pulled binding-point reads (UniformManager / VulkanRenderer), whose + // direct bufferObject->MarkGpuWritten() was the apply thread poking client memory (R2). + // Routed through the reverse channel exactly as DirectGLES' MarkBufferGpuWritten routes + // its own sites: ONE whole-buffer range, stated rather than implied (zero ranges is the + // shape a fully narrowed announcement will legitimately have at P8/P9, and the two must + // not be the same record). The handle comes from the client allocator's lifetime-id + // probe - the mint is unconditional at the BufferObject constructor + // (MGPipeMintResourceHandle), so a live buffer always has one. + // + // The fallback is the monolith shape, preserved byte for byte: no reverse channel + // installed (a pull-arm build, or a process with no session) pokes the object directly, + // which is exactly what these sites did before. A MISSING HANDLE with a live channel is + // loud rather than a silent drop, the same choice MarkBufferGpuWritten makes. + inline void MGPipeAnnounceBufferGpuWritten( + const SharedPtr& bufferObject) { + if (bufferObject == nullptr) return; + if (gMGPipeCallbacks.OnGpuWritten == nullptr) { + bufferObject->MarkGpuWritten(); + return; + } + const MGPipeHandle res = [&]() { +#if MOBILEGL_BUILD_DISAGGREGATED + // CONTRACT-P5C §3.1's named exemption, Magma's half: the binding records that + // would carry this buffer's handle are sb's to emit and Magma's server-side + // binding table is P7's, so until then the probe runs inside the scope - the + // debt's named, greppable form rather than a silent guard removal. + // + // P5e (id), ruling 12: the FOURTH of Magma's apply-thread allocator debts and the + // last user of the scope P5e renamed. Espryt never reaches this arm under a + // transport - MarkBufferGpuWrittenByHandle carries the handle from the record side + // (sb/fb) - so the DirectVulkan key on the exemption costs it nothing. + const MagmaP7AllocatorDebtScope magmaP7AllocatorDebt; +#endif + return MGPipeSlots().FindByLifetimeId(MGPipeKind::Buffer, bufferObject->GetLifetimeId()); + }(); + if (MGPipeHandleIsNull(res)) { + MGLOG_E_ONCE("MGPipe: no handle for the GPU-write announcement of buffer %u - the " + "reverse channel is installed but the mint is missing, so the mark " + "would be dropped at the consumer; poking the object directly rather " + "than losing it", + bufferObject->GetExternalIndex()); + bufferObject->MarkGpuWritten(); + return; + } + const MGPRange whole{0, kMGPipeWholeBuffer}; + gMGPipeCallbacks.OnGpuWritten(res, 1, &whole); + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/SamplerEmit.h b/MobileGL/MG_Impl/Pipe/SamplerEmit.h new file mode 100644 index 000000000..21318fb7e --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SamplerEmit.h @@ -0,0 +1,1086 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SamplerEmit.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 + +// The CLIENT side of P4a's sampler family: the content-addressed sampler CSO cache, the +// identity-addressed sampler view per texture object, and the two unit sets +// set_sampler_views and bind_sampler_states. The third unit set, set_shader_images, is +// ImageEmit.h's - the same subsystem bit, a different resolution. +// +// TWO THINGS THIS FILE OWNS THAT ARE EASY TO GET WRONG, both stated where the body will go: +// * SamplerParameters is 100 bytes with THREE BYTES OF TRAILING PADDING, so the CSO cache +// hashes and memcmp-confirms over a ZERO-INITIALISED canonical copy built field by field, +// never over the object's own bytes. Without that the 256-entry cache's hit rate is zero +// and nobody notices, because the pixels are right. +// * every emission goes through a VERSION-FIRST SKIP before it hashes anything: the sampler +// view latches (params version, shape version) per handle, and the two sets latch their +// SetHashSuppressor slots. A 192-entry walk per verb without a latch is not affordable. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full. kMGPipeWiredSamplerSubsystem below covers this file AND +// ImageEmit.h: the three unit sets, the sampler CSO and the sampler view are ONE family and +// one subsystem bit, because an operator switching samplers off has to get the whole family's +// legacy arm rather than two thirds of it. +// +// WHAT THIS FILE IS THE CLIENT HALF OF, named so a reader can check it against the oracle: +// DirectGLES' ResolveAndBindUnitTextures (the per-unit sampler-view resolution), +// BindCurrentUnitSamplers (the per-unit sampler-object walk) and the program pass' sampler +// override. TWO BACKEND POST-PROCESSINGS DELIBERATELY STAY ON THE SERVER and act on the +// RESOLVED set: Espryt's raw-depth-fetch sampler substitution and Magma's feedback-loop +// detection. Neither is reproduced here and neither may be. +// +// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace MobileGL::MG_Pipe { + + // WIRED. The sampler CSO, the sampler view and all three unit sets - set_shader_images + // included, whose emitter lives in ImageEmit.h - have bodies, so this file's family + // contributes its bit to kMGPipeWiredSubsystems. ONE bit for the whole family, because an + // operator switching samplers off has to get the whole family's legacy arm rather than two + // thirds of it. + // + // WHAT THE BIT DOES, and it really is a gate - c0b changed this and the note that used to + // say the opposite was true only against the contract commit. The validate point's + // `wants()` (PipeFill.cpp) now asks FOUR things: the bit maps to a subsystem, the + // operator's MOBILEGL_PIPE_PUSH mask carries it, `kMGPipeWiredSubsystems` carries it - + // i.e. this constant is non-zero - and the dirty bit fired. The birth hooks' own gate + // (`FamilyIsLive`) is the same pair one level in, so the client paths and the validate + // point cannot disagree. So an emitter with a body and this constant still 0 is called by + // nothing and emits nothing, and flipping the constant is what switches the family on. + // + // IT IS ALSO A COMPILE-TIME CONTRACT. While it is non-zero, PipeFill.cpp's `if constexpr` + // seam instantiates the forward to this family's entry points - EmitSamplerCso and + // EmitSamplerView below - so a missing or misspelled one is a build error in this file's + // own commit rather than a surprise at the merge. + // + // The A/B that switches this family off at RUNTIME is still the mask; this constant is + // what a build declares it emits for, and it feeds EmittedCallSuppliesTheWholeField's + // guard. ONE bit for the whole family, for the reason above. + inline constexpr Uint64 kMGPipeWiredSamplerSubsystem = kMGPipeSubsystemSamplers; + + // --------------------------------------------------------------------------------- + // D-F1: the canonical SamplerParameters copy, and why it is not a memcpy + // --------------------------------------------------------------------------------- + // + // sizeof(SamplerParameters) == 100 and its members occupy 97 of those bytes: six 4-byte + // enums, four Floats, two more 4-byte enums, three 16-byte colour vectors and the one-byte + // borderColorForm. Bytes 97, 98 and 99 are PADDING and no writer ever touches them. + // + // A cache that hashed or memcmp'd the object's own bytes would therefore read + // uninitialised memory. In practice SamplerObject value-initialises its member and never + // rewrites it, so in practice those bytes are stable - and "in practice" is not a + // contract. The whole point of a content-addressed cache is that a false MISS mints a + // fresh CSO per call: a 256-entry cache with a hit rate of zero, on a path nobody looks + // at, because the pixels are right either way. + // + // So every hash and every confirm runs over a copy that is memset to zero FIRST and then + // assigned field by field, which makes the padding deterministically zero on both sides of + // the comparison. The field list is MGP_FIELDS_SamplerParameters', in its order, and the + // verify build compares the same sixteen fields one at a time - without that + // PipeFields.def row the blob would be compared as bytes and G4 would be a coin flip. + // + // ONE COPY PER MINT ATTEMPT, never per draw: the version-first skip in the two emitters + // below decides whether to come here at all. + // THE PRIMITIVE TAKES AN OUT-PARAMETER, and that is not a style preference either. A + // returned SamplerParameters is copied, and a copy of a trivially copyable type leaves the + // padding UNSPECIFIED - so a canonicaliser that returned by value would hand its caller a + // value whose three trailing bytes are whatever the copy left there, which is the very + // thing this function exists to make deterministic. Every producer of canonical bytes in + // this file, and every consumer that stores them, goes through this and through memcpy. + inline void MGPipeCanonicaliseSamplerParameters(const SamplerParameters& src, SamplerParameters& canon) { + static_assert(std::is_trivially_copyable_v, + "the canonical copy is memset and then assigned field by field"); + std::memset(static_cast(&canon), 0, sizeof(canon)); + canon.wrapS = src.wrapS; + canon.wrapT = src.wrapT; + canon.wrapR = src.wrapR; + canon.minFilter = src.minFilter; + canon.magFilter = src.magFilter; + canon.mipmapMode = src.mipmapMode; + canon.minLod = src.minLod; + canon.maxLod = src.maxLod; + canon.lodBias = src.lodBias; + canon.maxAnisotropy = src.maxAnisotropy; + canon.compareFunc = src.compareFunc; + canon.compareMode = src.compareMode; + canon.borderColor = src.borderColor; + canon.borderColorI = src.borderColorI; + canon.borderColorUI = src.borderColorUI; + // ALL FOUR BORDER-COLOUR MEMBERS CROSS, the form included. All three representations + // are always numerically populated, so the value alone cannot say which driver entry + // point applies (glSamplerParameterIiv vs fv, or which VkBorderColor family), and both + // of Espryt's redundancy filters compare all four. Dropping this one line is G7's + // scripted negative control and SamplerEmit's suite must go red naming it. + canon.borderColorForm = src.borderColorForm; + } + + inline Uint64 MGPipeHashSamplerParameters(const SamplerParameters& canon) { + return XXH64(&canon, sizeof(canon), 0); + } + + // Canonicalise and hash in one step, for a caller that wants only the hash. + inline Uint64 MGPipeHashOfSamplerParameters(const SamplerParameters& src) { + SamplerParameters canon; + MGPipeCanonicaliseSamplerParameters(src, canon); + return MGPipeHashSamplerParameters(canon); + } + + // --------------------------------------------------------------------------------- + // D-F1: the content-addressed sampler CSO cache, capacity 256 + // --------------------------------------------------------------------------------- + // + // Unlike P3a's vertex-elements CSO, content addressing is RIGHT here: a SamplerObject is a + // pure 100-byte value with no driver-side per-object binding state, two identical samplers + // can share one CSO with no extra work on either side, and Espryt's BackendSamplerObject + // is a driver name plus a parameter shadow - nothing a second frontend object would have + // to re-establish. + // + // THE LOOKUP IS CsoCache.h's, one for one: hash, probe, and CONFIRM WITH A MEMCMP before + // reusing a handle, because a bare 64-bit equality would alias two different sampler + // states onto one CSO and that is silent wrong filtering with no gate that can see it. + // + // WHAT KEEPS A LIVE HANDLE OUT OF THE LRU's REACH IS A REFERENCE COUNT, not the capacity + // (ID-17). The capacity argument - "one pass acquires at most kMGPipeMaxTextureUnits == 192 + // CSOs and touches every one of them, so LRU can only evict an entry from an EARLIER pass" + // - closes the INTRA-PASS case only, and the case that matters is the other one: a + // MGPTextureParams record published in an earlier pass names its texture's BuiltinSampler + // for as long as that texture's parameters do not move, the applier deliberately does not + // resolve that handle, and an eviction is not a parameter change, so nothing re-emits and + // nobody refuses. With more than 256 distinct live sampler values - a CTS sampler sweep, a + // scene with many filter/wrap/border combinations - the rarely-touched built-in samplers + // are the FIRST victims. + // + // So every Acquire takes a REFERENCE and Release drops one; LRU considers only entries + // whose count is 0; and when every entry is pinned the cache MINTS BEYOND CAPACITY and + // counts it (Counters::OverCapacityMints). Growing is the safe direction - a slot too many + // costs memory, a handle pulled out from under a live record costs correctness - and the + // count is a recorded number rather than a gate, so a workload that pins more than 256 + // values is visible instead of being wrong. + // + // WHO HOLDS A REFERENCE: package B's EmitTextureParams for a texture's built-in sampler + // (released when the texture dies or when a parameter change makes it re-acquire), and + // this file's own bind_sampler_states for every CSO a unit is currently bound to. Anything + // that acquires a handle it will still name after it returns must hold one. + // + // THE CAPACITY ITSELF IS STILL A SIZING BOUND worth asserting: a cache that could not hold + // one whole emission pass would over-capacity-mint on every single pass, which is a + // silently unbounded cache rather than a 256-entry one. + // + // THE SLOT IS ALLOCATED WITHOUT A LIFETIME ID, deliberately: a content-addressed CSO + // belongs to a VALUE and not to a frontend object, so ~SamplerObject must not free it - + // another live SamplerObject may hold the same value. MGPipeEmitSamplerCsoDestroyAndFree + // resolves nothing for such an id and correctly frees nothing; the only death path for + // these slots is the LRU eviction below, which is client-side and therefore + // backend-neutral on day one. + // + // PACKAGE D MUST BE TOLD, and it is the one consequence of that choice that reaches the + // server: NotifyStateObjectDestroyed(SamplerCso, lifetimeId) arrives from ~SamplerObject + // with NO HANDLE BEHIND IT, every time. A backend must NOT key a sampler twin on a + // SamplerObject's lifetime id. The twin's life is create_sampler_state -> the LRU's + // delete_sampler_state and nothing else. + inline constexpr SizeT kMGPipeSamplerCsoCacheCapacity = 256; + static_assert(kMGPipeSamplerCsoCacheCapacity > kMGPipeMaxTextureUnits, + "a cache that cannot hold one whole emission pass would mint over capacity on " + "every pass; correctness against eviction is the reference count, not this bound"); + + class MGPipeSamplerCsoCache { + public: + struct Counters { + Uint64 Mints = 0; // create_sampler_state emissions + Uint64 Acquisitions = 0; + Uint64 Hits = 0; // a probe that found a live entry and passed the memcmp + Uint64 Collisions = 0; // a hash hit the memcmp REJECTED - the reason it exists + Uint64 Evictions = 0; // LRU evictions, each one a delete_sampler_state + Uint64 Releases = 0; // reference drops + // Mints made with the cache already full and EVERY entry referenced. A recorded + // number and not a gate (ID-17): growing past 256 is the safe direction, and this + // is what makes it visible instead of silent. + // + // READ IT AS "HOW FAR THE CACHE PERMANENTLY GREW", not "how often it grew". Once + // m_entries.size() is past the capacity every later Mint evicts one unreferenced + // entry and pushes one, so the size stays at its high-water mark for the life of + // the process - along with that many SamplerCso slots in the allocator and that + // many create_sampler_state records in the applier. Bounded by the peak + // simultaneous pin count, so this is memory retention and not a leak; the cache + // never shrinks back. + Uint64 OverCapacityMints = 0; + // A RELEASE THIS CACHE COULD NOT ACCOUNT FOR. UnknownReleases: the handle names no + // entry here (a stale handle from before a ResetForTest, a handle of another kind + // passed by mistake, a handle whose entry was already evicted). UnderflowedReleases: + // the entry was found with a count already 0. Both are contract violations by a + // HOLDER and are counted rather than absorbed, because the pin they steal is + // exactly what stops the LRU taking a handle a published record still names - see + // Release(). Recorded numbers, not gates; MOBILEGL_ASSERT could not carry them + // because it compiles out at INFO, i.e. in every gate build and every shipped one. + Uint64 UnknownReleases = 0; + Uint64 UnderflowedReleases = 0; + // ID-17's Evict invariant, as a counter for the same reason (C2-n1). Mint's victim + // choice structurally guarantees it stays 0; it is what would say so in a shipped + // build if a second caller of Evict ever appeared. + Uint64 ReferencedEvictions = 0; + }; + + // The handle for `params`' value, AND A REFERENCE ON IT. Mints and emits + // create_sampler_state on a miss and emits delete_sampler_state for whatever it evicts + // to make room. `payloadBytes` accumulates what went on the wire. + // + // EVERY Acquire TAKES A REFERENCE and every caller owes exactly one Release for it + // (ID-17). A caller that only wants a transient handle still has to release it, and a + // caller that keeps naming the handle in a published record must hold its reference for + // as long as that record stands - that is what stops the LRU pulling the handle out + // from under it, because an eviction is not a parameter change and nothing re-emits. + // + // THIS IS ALSO PACKAGE B's SEAM. MGPTextureParams::BuiltinSampler names the CSO that + // carries the SamplerParameters of the SamplerObject every ITextureObject owns, and a + // null handle there is Fatal{ProtocolCorruption} rather than "no sampler". TextureEmit.h + // acquires it from here - taking a reference it holds until the texture dies or a + // parameter change makes it re-acquire, at which point it releases the previous handle - + // so the texture's built-in sampler and a glBindSampler'd sampler object with the same + // value share one CSO and one server-side twin, which is exactly the sharing that makes + // content addressing the right answer for this kind. + MGPipeHandle Acquire(const SamplerParameters& params, Uint64& payloadBytes) { + SamplerParameters canon; + MGPipeCanonicaliseSamplerParameters(params, canon); + return AcquireCanonical(MGPipeHashSamplerParameters(canon), canon, payloadBytes); + } + + // A TEST SEAM, and the only reason it is public. The collision branch below - the + // memcmp confirm, the Collisions counter and the probe that keeps going past a + // rejected entry - cannot be reached from Acquire without manufacturing a genuine + // 64-bit XXH64 collision, so without this the whole branch is uncovered and the case + // named for it proves something else. It hashes nothing and simply uses the hash it is + // given; production code has no reason to call it. + // + // THE THREE MEMBERS A CONSUMER MUST NOT CALL are this one, ResetForTest() (which frees + // every slot the cache holds and drops their publication latches) and ResetCounters(). + // Package B's seam is Acquire / Release / RefCountOf / RecordIsPublished / Size / + // GetCounters and nothing else - the class is public because it is header-only, not + // because all nine members are for callers. + MGPipeHandle AcquireWithForcedHashForTest(const SamplerParameters& params, Uint64 forcedHash, + Uint64& payloadBytes) { + SamplerParameters canon; + MGPipeCanonicaliseSamplerParameters(params, canon); + return AcquireCanonical(forcedHash, canon, payloadBytes); + } + + // Drops one reference. The entry stays - a released value is still worth reusing - it + // merely becomes eligible for the LRU again. Releasing a handle this cache never handed + // out, or releasing one twice, does not underflow: the death paths that call it are + // idempotent by contract and a second call must not corrupt the count. + // + // BUT THE COUNT IS PER HANDLE, NOT PER HOLDER, and that is the sentence a holder has to + // read. Entries are content-addressed and shared by design - package B's texture-params + // record and this file's own bind_sampler_states will routinely name the SAME handle, + // each owing its own Release - so a holder that releases twice is not harmlessly + // repeating itself: it takes ANOTHER holder's pin, the entry can reach RefCount 0 while + // a published MGPTextureParams and a standing bind_sampler_states set still name it, + // and the LRU may then evict it with nothing to refuse and nothing to re-emit. This + // cannot be repaired here, so it is COUNTED (UnknownReleases / UnderflowedReleases) + // rather than absorbed silently, and that is what a reviewer of a holder asserts on. + void Release(MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return; + for (Entry& entry : m_entries) { + if (entry.Cso != handle) continue; + if (entry.RefCount > 0) { + --entry.RefCount; + } else { + ++m_counters.UnderflowedReleases; + } + ++m_counters.Releases; + return; + } + ++m_counters.UnknownReleases; + } + + // How many live references this cache is holding for `handle`. Diagnostics and unit + // cases; nothing on the emission path asks. + Uint32 RefCountOf(MGPipeHandle handle) const { + if (MGPipeHandleIsNull(handle)) return 0; + for (const Entry& entry : m_entries) { + if (entry.Cso == handle) return entry.RefCount; + } + return 0; + } + + // "Does this cache still hold a create_sampler_state record for exactly this handle?" + // + // IT IS NOT THE DEATH PATH's AUTHORITY, and that changed at c0b: the six death helpers + // read A's publication latch (MGPipeHandleIsPublished, PipeMutation.h), which is one + // answer per {kind, slot, gen} written by whatever emitted the create. This stays + // because it is the CACHE's own question - "is this value still resident here" - which + // is a different question and is what a unit case reads. + Bool RecordIsPublished(MGPipeHandle handle) const { + if (MGPipeHandleIsNull(handle)) return false; + for (const Entry& entry : m_entries) { + if (entry.Cso == handle) return true; + } + return false; + } + + // A unit test's fixture, and nothing else. NOT called from the validate point's + // FreshlyPrimed arm: MGPipeApplierReset is a make-current and does NOT drop object + // records, so a sampler CSO the applier holds outlives a context switch. Dropping the + // cache there would leak the applier's record and re-mint a value it already has. + // + // IT DROPS THE PUBLICATION LATCH FOR EVERY ENTRY IT FREES. The slots go back with no + // delete_sampler_state behind them, so a latch left standing would make a future death + // helper - on the recycled slot at the same generation - emit a delete for a record + // that never existed, which is the refusal the applier counts. + void ResetForTest() { + for (const Entry& entry : m_entries) { + MGPipeNoteHandleUnpublished(MGPipeKind::SamplerCso, entry.Cso); + MGPipeSlots().Free(MGPipeKind::SamplerCso, entry.Cso); + } + m_entries.clear(); + m_clock = 0; + } + + void ResetCounters() { m_counters = Counters{}; } + SizeT Size() const { return m_entries.size(); } + const Counters& GetCounters() const { return m_counters; } + + private: + struct Entry { + Uint64 Hash = 0; + Uint64 LastUsed = 0; + MGPipeHandle Cso = kMGPipeNullHandle; + // THE PIN. Non-zero means some record that is still standing names this handle, so + // the LRU may not take it (ID-17). + Uint32 RefCount = 0; + // THE CANONICAL BYTES, not the caller's. This is the memcmp's other operand and it + // has to have the same deterministic padding the probe's copy has. + SamplerParameters Params{}; + }; + + MGPipeHandle AcquireCanonical(Uint64 hash, const SamplerParameters& canon, Uint64& payloadBytes) { + ++m_counters.Acquisitions; + for (SizeT i = 0; i < m_entries.size(); ++i) { + if (m_entries[i].Hash != hash) continue; + if (std::memcmp(&m_entries[i].Params, &canon, sizeof(canon)) != 0) { + // A 64-bit collision between two DIFFERENT sampler states. Reusing the + // handle would filter one state with the other's parameters, so this entry + // is not a match - and the probe KEEPS GOING rather than evicting and + // giving up. Evicting here would be a second eviction path the reference + // count would have to police for no gain (the colliding entry may be + // pinned, and it may even be one the record being built right now names), + // and stopping here would miss a LATER entry with the same hash whose bytes + // really do match and mint a duplicate CSO for a value the cache holds. + ++m_counters.Collisions; + continue; + } + m_entries[i].LastUsed = ++m_clock; + ++m_entries[i].RefCount; + ++m_counters.Hits; + return m_entries[i].Cso; + } + return Mint(hash, canon, payloadBytes); + } + + MGPipeHandle Mint(Uint64 hash, const SamplerParameters& canon, Uint64& payloadBytes) { + if (m_entries.size() >= kMGPipeSamplerCsoCacheCapacity) { + // THE LRU VICTIM IS CHOSEN AMONG UNREFERENCED ENTRIES ONLY. A referenced entry + // is named by a record that is still standing - a texture's BuiltinSampler, a + // bound sampler state - and nothing re-emits when a handle stops being valid, + // so evicting one is a silent corruption with no gate that can see it. + SizeT victim = m_entries.size(); + for (SizeT i = 0; i < m_entries.size(); ++i) { + if (m_entries[i].RefCount != 0) continue; + if (victim == m_entries.size() || m_entries[i].LastUsed < m_entries[victim].LastUsed) { + victim = i; + } + } + if (victim < m_entries.size()) { + Evict(victim); + } else { + // EVERY ENTRY IS PINNED, so the cache grows past its capacity and says so. + // A recorded number, not a gate (ID-17). + ++m_counters.OverCapacityMints; + } + } + + const MGPipeHandle cso = MGPipeSlots().Allocate(MGPipeKind::SamplerCso); + MGPSamplerDesc desc{}; + desc.Cso = cso; + // THE ONE BLOB RULE (MGPipeTypes.h): Size 0 means "this record does not declare its + // blob", which is what a monolith emission is - the parameters ride beside the + // record through the entry point's companion pointer and the applier stores them by + // value. Splitting this for a transport is P5's problem, not this emitter's. + desc.Parameters.Seg = kMGHostSpanSegNone; + desc.Parameters.Offset = 0; + desc.Parameters.Size = 0; + + // BUILT IN PLACE AND FILLED WITH A MEMCPY, not assigned from a local. This is the + // padding trap one level deeper than the one the design names: an assignment copies + // the sixteen MEMBERS and leaves the three trailing padding bytes of the + // destination at whatever was there, so the next probe's memcmp would reject its + // own entry, count a collision that never happened, evict and mint a fresh CSO - a + // cache with a hit rate of zero whose failure depends on heap contents, which is + // why it passes a case run alone and fails the same case run in a suite. The bytes + // stored here have to be the bytes compared later, padding included. + m_entries.push_back(Entry{}); + Entry& entry = m_entries.back(); + entry.Hash = hash; + entry.LastUsed = ++m_clock; + entry.Cso = cso; + // THE MINT IS ITSELF AN ACQUIRE, so the caller owes one Release for it exactly as + // it would for a hit. Anything else would make the first acquirer of a value the + // one caller whose handle the LRU could take. + entry.RefCount = 1; + std::memcpy(static_cast(&entry.Params), &canon, sizeof(canon)); + // The applier is handed the CACHE's copy, so the pointer stays valid for the whole + // call and the bytes it stores are provably the bytes the memcmp will confirm + // against later. + MGPipeRouteCreateSamplerState(desc, &m_entries.back().Params); + // THE CREATE ACTUALLY WENT OUT, so the publication latch is taken here and nowhere + // else (contract-v2 §3.1). It is what the six death helpers read, and without it a + // delete_sampler_state can never go out for this kind. + MGPipeNoteHandlePublished(MGPipeKind::SamplerCso, cso); + + ++m_counters.Mints; + payloadBytes += sizeof(MGPSamplerDesc) + sizeof(SamplerParameters); + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::CsoBlobBytes, + sizeof(SamplerParameters)); + } + return cso; + } + + // ONLY EVER CALLED WITH AN UNREFERENCED ENTRY - see Mint's victim choice. That is the + // whole of ID-17's ruling: a handle a standing record names cannot be taken, because + // the applier does not resolve MGPTextureParams::BuiltinSampler and an eviction is not + // a parameter change, so nothing would refuse and nothing would re-emit. + // + // COUNTED, NOT ASSERTED (C2-n1). MOBILEGL_ASSERT is live only at DEBUG, so an assertion + // here would be absent from all three gate builds and from every shipped build - the + // idiom D-J3 forbids and the one C-m6 removed from the program emitter. The eviction + // still proceeds when the count is non-zero: the sole caller guarantees the condition, + // and refusing here would leave Mint without the room it asked for. + void Evict(SizeT index) { + if (m_entries[index].RefCount != 0) ++m_counters.ReferencedEvictions; + MGPHandleOnly handle{}; + handle.Handle = m_entries[index].Cso; + handle.Kind = static_cast(MGPipeKind::SamplerCso); + // THE THREE-STEP ORDER, the same one PipeMutation.h fixes for the death helpers: + // the wire delete drops the applier's record while the record still exists, and + // only then does the slot go back. There is no NotifyStateObjectDestroyed step + // here - a content-addressed CSO has no frontend object whose death is being + // announced, which is precisely why this eviction is the only death path it has. + MGPipeRouteDeleteSamplerState(handle); + // AND THE LATCH GOES WITH THE DELETE. This is the "an emitter that drops a record + // for its own reasons calls MGPipeNoteHandleUnpublished" half of the publication + // protocol (contract-v2 §3.1): the record is gone, so a death helper reaching this + // slot after it is recycled must not emit a second delete for it. + MGPipeNoteHandleUnpublished(MGPipeKind::SamplerCso, m_entries[index].Cso); + MGPipeSlots().Free(MGPipeKind::SamplerCso, m_entries[index].Cso); + // A COPY-ASSIGNMENT, which does NOT carry padding - the same trap Mint's memcpy + // exists for, one level along. It is safe here only because every entry's padding + // was already made deterministically zero by that memcpy, so the assignment's + // memberwise copy of Params has nothing indeterminate to propagate and Vector's own + // growth memmoves a trivially copyable type. An Entry built any other way would + // break the cache's "the bytes stored are the bytes compared" invariant here rather + // than at the mint, which is much harder to see. + m_entries[index] = m_entries.back(); + m_entries.pop_back(); + ++m_counters.Evictions; + } + + Vector m_entries; + Uint64 m_clock = 0; + Counters m_counters; + }; + + inline MGPipeSamplerCsoCache& MGPipeSamplerCsoCacheInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason, and this one is on a death + // path: TextureEmit.h asks it for every texture's built-in sampler CSO, so an exit + // handler running a frontend destructor must not find it freed. + static MGPipeSamplerCsoCache* cache = new MGPipeSamplerCsoCache(); + return *cache; + } + + // --------------------------------------------------------------------------------- + // D-F3: the client-side sampling resolution + // --------------------------------------------------------------------------------- + // + // GL binds one texture per unit PER TARGET; which one the shader actually samples depends + // on the sampler uniform's TYPE. Gallium's one-view-per-slot is the resolved form, so the + // resolution moves to the client and the record carries the answer rather than the inputs. + // + // This is DirectGLES' SamplerUniformTextureTarget, moved to the side of the boundary that + // now owns the question. Targets with no sampler spelling map to Unknown, and a unit whose + // uniform type resolves to Unknown carries no view - which is exactly "the program does not + // sample this unit". + inline MobileGL::TextureTarget MGPipeSamplerUniformTextureTarget(GLenum uniformType) { + switch (uniformType) { + case GL_SAMPLER_1D: + case GL_INT_SAMPLER_1D: + case GL_UNSIGNED_INT_SAMPLER_1D: + case GL_SAMPLER_1D_SHADOW: + return TextureTarget::Texture1D; + case GL_SAMPLER_2D: + case GL_INT_SAMPLER_2D: + case GL_UNSIGNED_INT_SAMPLER_2D: + case GL_SAMPLER_2D_SHADOW: + return TextureTarget::Texture2D; + case GL_SAMPLER_3D: + case GL_INT_SAMPLER_3D: + case GL_UNSIGNED_INT_SAMPLER_3D: + return TextureTarget::Texture3D; + case GL_SAMPLER_CUBE: + case GL_INT_SAMPLER_CUBE: + case GL_UNSIGNED_INT_SAMPLER_CUBE: + case GL_SAMPLER_CUBE_SHADOW: + return TextureTarget::TextureCubeMap; + case GL_SAMPLER_1D_ARRAY: + case GL_INT_SAMPLER_1D_ARRAY: + case GL_UNSIGNED_INT_SAMPLER_1D_ARRAY: + case GL_SAMPLER_1D_ARRAY_SHADOW: + return TextureTarget::Texture1DArray; + case GL_SAMPLER_2D_ARRAY: + case GL_INT_SAMPLER_2D_ARRAY: + case GL_UNSIGNED_INT_SAMPLER_2D_ARRAY: + case GL_SAMPLER_2D_ARRAY_SHADOW: + return TextureTarget::Texture2DArray; + case GL_SAMPLER_CUBE_MAP_ARRAY: + case GL_INT_SAMPLER_CUBE_MAP_ARRAY: + case GL_UNSIGNED_INT_SAMPLER_CUBE_MAP_ARRAY: + case GL_SAMPLER_CUBE_MAP_ARRAY_SHADOW: + return TextureTarget::TextureCubeMapArray; + case GL_SAMPLER_2D_RECT: + case GL_INT_SAMPLER_2D_RECT: + case GL_UNSIGNED_INT_SAMPLER_2D_RECT: + case GL_SAMPLER_2D_RECT_SHADOW: + return TextureTarget::TextureRectangle; + case GL_SAMPLER_2D_MULTISAMPLE: + case GL_INT_SAMPLER_2D_MULTISAMPLE: + case GL_UNSIGNED_INT_SAMPLER_2D_MULTISAMPLE: + return TextureTarget::Texture2DMultisample; + case GL_SAMPLER_2D_MULTISAMPLE_ARRAY: + case GL_INT_SAMPLER_2D_MULTISAMPLE_ARRAY: + case GL_UNSIGNED_INT_SAMPLER_2D_MULTISAMPLE_ARRAY: + return TextureTarget::Texture2DMultisampleArray; + case GL_SAMPLER_BUFFER: + case GL_INT_SAMPLER_BUFFER: + case GL_UNSIGNED_INT_SAMPLER_BUFFER: + return TextureTarget::TextureBuffer; + default: + return TextureTarget::Unknown; + } + } + + // Unit -> sampled target, and the highest image unit the program names, both INVERTED ONCE + // per program state rather than searched per unit. + // + // DirectGLES asks the question the other way round - for one unit it walks every uniform + // location - and gets away with it because it only asks on an aliasing conflict. A client + // that asked per unit would be O(units x locations) at every verb the sampler bit fires + // on, which is exactly the per-draw cost the phase's budget forbids. So the walk runs once + // and is memoised on (lifetime id, link version, backend state version): the third is the + // counter SetUniformSamplerOrImageUnitIndex bumps, i.e. the one thing that can move a + // uniform's unit without relinking. + // + // IT IS SHARED WITH ImageEmit.h on purpose. The sampler and image halves come out of one + // walk over one array, they ride one subsystem bit, and computing them separately would + // walk the same locations twice per program change. + class MGPipeProgramOpaqueUnits { + public: + using ProgramObject = MG_State::GLState::ProgramObject; + + struct Resolution { + // TextureTarget + 1 per unit, 0 meaning "this program samples nothing here". A + // Uint8 because TextureTargetCount is 11 and this array is 192 entries long on a + // path that wants to stay in cache. + Array SamplerTarget{}; + // Highest image unit the program names, or -1 for a program with no image + // uniforms - which is what gives ImageEmit.h its zero early-out for free. + Int32 MaxImageUnit = -1; + }; + + const Resolution& For(const ProgramObject* program) { + const Uint64 lifetimeId = program != nullptr ? program->GetLifetimeId() : 0; + const Uint32 linkVersion = program != nullptr ? program->GetLinkVersion() : 0; + const Uint32 stateVersion = program != nullptr ? program->GetBackendStateVersion() : 0; + if (m_valid && m_lifetimeId == lifetimeId && m_linkVersion == linkVersion && + m_stateVersion == stateVersion) { + return m_resolution; + } + m_resolution = Resolution{}; + // GetLinkStatus is the same guard DirectGLES uses before it trusts the reflection: + // a program that did not link has no usable uniform table, and every unit resolves + // to "not sampled". + if (program != nullptr && program->GetLinkStatus()) { + const Uint maxLocation = program->GetMaxUniformLocation(); + for (Uint location = 0; location <= maxLocation; ++location) { + const Int unit = program->GetUniformSamplerOrImageUnitIndex(location); + if (unit < 0 || unit >= static_cast(kMGPipeMaxTextureUnits)) continue; + if (program->GetUniformTypeFacts(location).isImage) { + if (unit > m_resolution.MaxImageUnit) m_resolution.MaxImageUnit = unit; + continue; + } + const TextureTarget target = + MGPipeSamplerUniformTextureTarget(program->GetUniformType(location)); + if (target == TextureTarget::Unknown) continue; + // FIRST WRITER WINS, which is DirectGLES' arbitration read forwards: when + // two sampler uniforms of different types share a unit the binding placed + // first stands rather than being silently overwritten by whichever location + // comes last. + Uint8& slot = m_resolution.SamplerTarget[static_cast(unit)]; + if (slot == 0) slot = static_cast(static_cast(target) + 1); + } + } + m_lifetimeId = lifetimeId; + m_linkVersion = linkVersion; + m_stateVersion = stateVersion; + m_valid = true; + return m_resolution; + } + + void Invalidate() { m_valid = false; } + + private: + Resolution m_resolution; + Uint64 m_lifetimeId = 0; + Uint32 m_linkVersion = 0; + Uint32 m_stateVersion = 0; + Bool m_valid = false; + }; + + // ONE inversion for the whole family, shared by MGPipeSamplerEmitter and + // MGPipeImageEmitter. Two memos would walk the same uniform table twice per program + // change, and - worse - could disagree about which locations they saw, which is how a + // sampler unit and an image unit come to be resolved against two different readings of one + // program. Never destroyed, like every other MGPipe process singleton. + inline MGPipeProgramOpaqueUnits& MGPipeProgramOpaqueUnitsShared() { + static MGPipeProgramOpaqueUnits* units = new MGPipeProgramOpaqueUnits(); + return *units; + } + + // --------------------------------------------------------------------------------- + // D-G3: the two unit sets' content hashes + // --------------------------------------------------------------------------------- + // + // XXH64 over the tail entries, with Start and Count mixed in through MGPipeMixShutter - + // the VertexInputEmit shape, and for its reason: the hash must cover EVERY input the + // record carries, or a record whose one changed field is outside the tail gets suppressed. + // Both tails are built into zero-initialised staging arrays, so no padding byte enters + // either hash. + inline Uint64 MGPipeSamplerViewSetContentHash(const MGPBoundView* entries, Uint32 start, Uint32 count) { + Uint64 hash = XXH64(entries, static_cast(count) * sizeof(MGPBoundView), 0); + hash = MGPipeMixShutter(hash, start); + hash = MGPipeMixShutter(hash, count); + return hash; + } + + inline Uint64 MGPipeSamplerStateSetContentHash(const MGPipeHandle* entries, Uint32 start, Uint32 count) { + Uint64 hash = XXH64(entries, static_cast(count) * sizeof(MGPipeHandle), 0); + hash = MGPipeMixShutter(hash, start); + hash = MGPipeMixShutter(hash, count); + return hash; + } + + // --------------------------------------------------------------------------------- + // The emitter + // --------------------------------------------------------------------------------- + + class MGPipeSamplerEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + using ITextureObject = MG_State::GLState::ITextureObject; + using SamplerObject = MG_State::GLState::SamplerObject; + + // set_sampler_views: the PROGRAM-RESOLVED set only, one entry per unit, no stage + // dimension. Start is 0 and Count is GetMaxTouchedTextureUnit() + 1 clamped to the + // wire bound - the high-water mark is directly the count argument and is not + // re-derived. + // + // WHAT A UNIT'S ENTRY MEANS, and all three cases are legal rather than holes: + // the program resolves no target here -> View and Texture both null + // it resolves one, nothing is bound -> View and Texture both null + // it resolves one and a texture is bound-> Texture is that object's handle and View + // is its sampler-view CSO + // An UNDEFINED DEFAULT texture (name 0 with no image) and a texture that + // SAMPLES AS INCOMPLETE are both dropped to null here, which is the resolution + // DirectGLES performs by leaving the native target unbound: an incomplete texture + // samples (0,0,0,1) and the driver cannot work that out for itself, because the + // backend storage is immutable and never saw the level the application redefined at + // the wrong size. + Uint64 EmitSamplerViews(GLContext& ctx) { + const Int maxTouched = ctx.GetMaxTouchedTextureUnit(); + const Uint32 count = + maxTouched < 0 ? 0u + : Min(static_cast(maxTouched) + 1u, kMGPipeMaxTextureUnits); + + // The join is the EMITTER's, deliberately, and it is the same GetProgramForDraw() + // the verb is about to make anyway: the tracker's own shutters read + // GetCurrentProgram() precisely so that answering "did the shader move" never + // forces a compile. In the ladder EmitShaderState runs before this, so in the + // steady state the program is already joined by the time this line runs. + const auto& program = ctx.GetProgramForDraw(); + const auto& resolution = MGPipeProgramOpaqueUnitsShared().For(program.get()); + + Uint64 bytes = 0; + for (Uint32 unit = 0; unit < count; ++unit) { + MGPBoundView& entry = m_views[unit]; + entry = MGPBoundView{}; + entry.Unit = unit; + entry.View = kMGPipeNullHandle; + entry.Texture = kMGPipeNullHandle; + + const Uint8 encoded = resolution.SamplerTarget[unit]; + if (encoded == 0) continue; + const auto target = static_cast(static_cast(encoded) - 1); + + auto& textureUnit = ctx.GetTextureUnitObject(static_cast(unit)); + const auto& texture = textureUnit.GetBindingSlot(target).GetBoundObject(); + if (!texture) continue; + if (MG_State::GLState::IsUndefinedDefaultTexture(texture.get())) continue; + // The unit's sampler object overrides the texture's own, exactly as in GL, and + // the completeness answer depends on which one applies - a mipmap mode of None + // makes a single-level texture complete that would otherwise sample black. + const auto& unitSampler = textureUnit.GetSamplerObject(); + const SamplerObject* effective = + unitSampler ? unitSampler.get() : texture->GetSamplerObject().get(); + if (MG_State::GLState::SamplesAsIncompleteTexture(texture.get(), effective)) continue; + + entry.Texture = MGPipeSlots().Acquire(MGPipeKind::Texture, texture->GetLifetimeId()); + // D-A4: a texture the sampler-view resolution names in an emitted MGPBoundView + // is SAMPLER-bound from then on (sticky; the texture emitter's contract door, + // since this header is included BY TextureEmit.h). One early-out per unit per + // pass once the bit is set. + MGPipeNoteTextureBoundAs(entry.Texture, static_cast(kMGPipeBindSampler)); + entry.View = AcquireSamplerView(*texture, entry.Texture, bytes); + } + + const Uint64 hash = MGPipeSamplerViewSetContentHash(m_views.data(), 0, count); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetSamplerViews, hash)) { + return bytes; + } + m_lastViews = MGPSamplerViews{}; + m_lastViews.Start = 0; + m_lastViews.Count = count; + m_lastViews.ContentHash = hash; + MGPipeRouteSetSamplerViews(m_lastViews, m_views.data()); + ++m_viewSets; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::SamplerViewEmissions, 1); + } + return bytes + sizeof(MGPSamplerViews) + static_cast(count) * sizeof(MGPBoundView); + } + + // bind_sampler_states: the unit's sampler CSO, or the null handle when the unit has no + // sampler object - the texture's built-in sampler then applies, exactly as today. + // + // NOT program-resolved, and that asymmetry with the view set above is deliberate: a + // sampler object is bound to a UNIT and applies to whatever the unit holds, so its set + // is the plain per-unit walk BindCurrentUnitSamplers already does. A redundant re-bind + // of the same sampler object emits nothing at all, which is the whole point of the + // suppressor slot: GetTextureBindGeneration() bumps on a redundant re-bind - 26.2 + // rebinds the same sampler at every texture-unit switch - so without it this would be + // a several-hundred-byte variable-length record per batch. + // A BOUND SAMPLER STATE HOLDS A REFERENCE ON ITS CSO FOR AS LONG AS IT IS BOUND + // (ID-17). 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; the reference + // is what makes that true. The reconciliation below walks the unit array once: Acquire + // has already taken a reference for every unit in the new set, so a unit whose handle + // did not move gives that duplicate back, a unit whose handle moved gives back the one + // it used to hold, and a unit that fell outside the window gives back its own. + // + // ITS COST IS NOT "ONE EXTRA ARRAY WALK" and the ID-19 budget entry says so. Each + // Release of a NON-NULL handle is itself a linear scan of the whole cache, sitting + // beside an Acquire probe that is another, so a pass in which K units hold sampler CSOs + // costs 2 * K * O(cacheSize) handle comparisons - up to ~98 000 at K == 192 with a full + // cache, and unbounded above that because the over-capacity path lets the cache grow + // past 256. In Minecraft-shaped workloads K is typically 0: no unit has a SamplerObject + // bound, so Acquire is never called and Release(null) returns immediately. The workload + // that has both a large K and a large cache is a CTS sampler sweep. c0d moved the + // firing rate underneath this as well - bit 13's shutter now mixes + // GetTextureBindGeneration(), so this runs on EVERY texture bind at any unit and not + // only on a parameter change, and the set-hash suppressor below stops the record going + // out but not these two scans, which happen first. The per-unit (sampler lifetime id, + // ctx.GetSamplingResolutionGeneration()) latch recorded under ID-19 as C-m2 removes + // BOTH scans, not one. + Uint64 EmitSamplerStates(GLContext& ctx) { + const Int maxTouched = ctx.GetMaxTouchedTextureUnit(); + const Uint32 count = + maxTouched < 0 ? 0u + : Min(static_cast(maxTouched) + 1u, kMGPipeMaxTextureUnits); + + Uint64 bytes = 0; + MGPipeSamplerCsoCache& cache = MGPipeSamplerCsoCacheInstance(); + for (Uint32 unit = 0; unit < count; ++unit) { + const auto& sampler = ctx.GetTextureUnitObject(static_cast(unit)).GetSamplerObject(); + m_states[unit] = + sampler ? cache.Acquire(sampler->GetAllSamplerParameters(), bytes) : kMGPipeNullHandle; + } + for (Uint32 unit = 0; unit < kMGPipeMaxTextureUnits; ++unit) { + const MGPipeHandle held = m_stateRefs[unit]; + const MGPipeHandle now = unit < count ? m_states[unit] : kMGPipeNullHandle; + if (held == now) { + // The same CSO as last time: give back the duplicate reference this pass's + // Acquire took and keep the one that was already held. + if (unit < count) cache.Release(now); + continue; + } + cache.Release(held); + m_stateRefs[unit] = now; + } + + const Uint64 hash = MGPipeSamplerStateSetContentHash(m_states.data(), 0, count); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::BindSamplerStates, hash)) { + return bytes; + } + m_lastStates = MGPSamplerStates{}; + m_lastStates.Start = 0; + m_lastStates.Count = count; + m_lastStates.ContentHash = hash; + MGPipeRouteBindSamplerStates(m_lastStates, m_states.data()); + ++m_stateSets; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::SamplerStateEmissions, 1); + } + return bytes + sizeof(MGPSamplerStates) + static_cast(count) * sizeof(MGPipeHandle); + } + + // D-F2: ONE SamplerViewCso PER ITextureObject, minted off its lifetime id, and + // create_sampler_view RE-ISSUED ON THE SAME HANDLE whenever the restrictions move - + // legal because Gen increments only on slot reuse and never on a respecify. + // + // A DEVIATION FROM THE CONTENT-ADDRESSED 4096-ENTRY CACHE the design gives this kind, + // and it is P3a's vertex-elements deviation for the same reason: MobileGL has no + // frontend sampler-view object at all, so EVERY sampled texture needs one minted, and + // a content-addressed mint per texture per verb is exactly the per-draw cost the + // budget forbids. The content-addressed cache is right when Magma's image-view factory + // takes the CSO over and its VkImageView create-info is what is being addressed. + // + // THE VERSION-FIRST SKIP is the shape version, which is what BumpShapeVersion moves on + // every shape and format change - i.e. on exactly the inputs MGPSamplerView carries. + // The params version rides beside it belt-and-braces: no field of the record depends on + // it, so a wrap of that Uint16 can only cost a skipped re-issue of an identical record. + MGPipeHandle AcquireSamplerView(const ITextureObject& texture, MGPipeHandle textureHandle, + Uint64& payloadBytes) { + const MGPipeHandle handle = + MGPipeSlots().Acquire(MGPipeKind::SamplerViewCso, texture.GetLifetimeId()); + const SizeT slot = handle.Slot; + if (slot >= m_viewLatch.size()) m_viewLatch.resize(slot + 1); + ViewLatch& latch = m_viewLatch[slot]; + + const Uint64 shapeVersion = texture.GetShapeVersion(); + const Uint16 paramsVersion = texture.GetTextureParamsVersion(); + if (latch.RecordLive && latch.RecordGen == handle.Gen && latch.ShapeVersion == shapeVersion && + latch.ParamsVersion == paramsVersion && latch.Texture == textureHandle) { + return handle; + } + + m_lastView = MGPSamplerView{}; + m_lastView.Cso = handle; + m_lastView.Texture = textureHandle; + // THE ALIASING FORMAT. For a glTextureView this is the view's own internal format + // and not the storage owner's, which is the whole point of the call; for an + // ordinary texture it is simply its format. + m_lastView.InternalFormat = static_cast(texture.GetFormat()); + m_lastView.Target = static_cast(texture.GetTarget()); + // THE FOUR RESTRICTIONS COME FROM ONE PLACE. TextureObjectBase leaves all four at + // 0 for an ordinary texture and glTextureView writes them for a view, and a view + // always has NumLevels >= 1 - so a zero here unambiguously means "no restriction, + // the whole storage" and there is no second spelling of the unrestricted case that + // could disagree with the first. + m_lastView.MinLevel = static_cast(texture.GetViewMinLevel()); + m_lastView.NumLevels = static_cast(texture.GetViewNumLevels()); + m_lastView.MinLayer = static_cast(texture.GetViewMinLayer()); + m_lastView.NumLayers = static_cast(texture.GetViewNumLayers()); + m_lastView.Samples = static_cast(texture.GetSamples() < 0 ? 0 : texture.GetSamples()); + m_lastView.FixedSampleLocations = texture.HasFixedSampleLocations() ? 1 : 0; + MGPipeRouteCreateSamplerView(m_lastView); + // THE CREATE WENT OUT, so the publication latch is taken (contract-v2 §3.1). The + // texture's death helper reads it, and without it delete_sampler_view can never go + // out - the C-1 leak, one kind later. Re-taking it on a re-issue is right and + // cheap: the latch is keyed {kind, slot, gen} and the re-issue is on the same + // handle, so this writes the answer it already held. + MGPipeNoteHandlePublished(MGPipeKind::SamplerViewCso, handle); + ++m_viewCreates; + payloadBytes += sizeof(MGPSamplerView); + + latch.RecordLive = true; + latch.RecordGen = handle.Gen; + latch.ShapeVersion = shapeVersion; + latch.ParamsVersion = paramsVersion; + latch.Texture = textureHandle; + return handle; + } + + // ---- THE TWO CONTRACT ENTRY POINTS THIS FAMILY OWES (contract-v2 §3.4) ---- + // + // PipeFill.cpp's birth hooks forward here through an `if constexpr` seam keyed on + // kMGPipeWiredSamplerSubsystem, so while that constant is non-zero these two must + // exist and be spelled exactly like this or the build fails in this file's own commit. + // The hook has already applied BOTH gates (the operator's mask and the wired constant); + // what belongs here is the family's HANDLE RULE and its publication, which the contract + // deliberately does not pick for us because the three families disagree about identity. + // + // MINTING A SAMPLER CSO AT CONSTRUCTION TIME CONTENT-ADDRESSES THE DEFAULT PARAMETERS, + // and that is correct rather than wasteful: the cache dedupes, so a thousand + // freshly-created SamplerObjects share ONE create_sampler_state, and the moment the + // application sets a parameter the next acquire content-addresses the new value and + // this object simply stops naming the default entry. It is one create_sampler_state per + // distinct default-valued sampler, not one per object. + // + // THE REFERENCE THIS TAKES IS DROPPED IMMEDIATELY. A birth is not a standing record: no + // MGPTextureParams and no bind_sampler_states names this handle yet, so pinning the + // entry here would keep the default-parameter value un-evictable for the life of the + // process for no reader's benefit. Whoever later names the handle in a record takes its + // own reference through its own Acquire. + void EmitSamplerCso(SamplerObject& sampler) { + Uint64 bytes = 0; + MGPipeSamplerCsoCache& cache = MGPipeSamplerCsoCacheInstance(); + cache.Release(cache.Acquire(sampler.GetAllSamplerParameters(), bytes)); + } + + // The birth half of D-F2's one-view-per-texture rule, and it is a thin wrapper on + // purpose: AcquireSamplerView below is the whole handle rule - the identity-addressed + // mint off the texture's lifetime id, the version-first skip and the publication - and + // a second copy of any of it here would be a second authority. The texture handle is + // resolved exactly as EmitSamplerViews resolves it, and the byte count is discarded + // because a birth is not a validate-point emission and has no payload budget to report. + void EmitSamplerView(ITextureObject& texture) { + Uint64 bytes = 0; + const MGPipeHandle textureHandle = + MGPipeSlots().Acquire(MGPipeKind::Texture, texture.GetLifetimeId()); + AcquireSamplerView(texture, textureHandle, bytes); + } + + // The emitter's OWN record memo for a sampler view - "have I already published a + // create_sampler_view at this slot, for this generation". + // + // IT IS NOT WHAT THE DEATH PATH ASKS, and that changed at c0b (contract-v2 §3.1/D17): + // MGPipeEmitSamplerViewCsoDestroyAndFree reads A's publication latch + // (MGPipeHandleIsPublished), which is one answer per {kind, slot, gen} that every + // emitter writes and all six death helpers read - P4a has six kinds behind four + // emitters and one kind with no frontend object at all, so a per-emitter predicate has + // no well-defined owner. This stays because the VERSION-FIRST SKIP needs it: it is the + // same latch AcquireSamplerView consults before it hashes or copies anything. + Bool RecordIsPublished(MGPipeHandle handle) const { + if (MGPipeHandleIsNull(handle)) return false; + const SizeT slot = handle.Slot; + if (slot >= m_viewLatch.size()) return false; + const ViewLatch& latch = m_viewLatch[slot]; + return latch.RecordLive && latch.RecordGen == handle.Gen; + } + + // The memo's other half, for a caller that knows the applier has dropped this record. + // THE CALLER IS THE CONTRACT's DEATH HELPER (P4a final review C-2): the texture's + // helper drops the sampler view minted off the texture's lifetime id and forwards here + // before the slot is freed, so a dead handle no longer reads as published in this memo + // between the death and the recycle. Gen-keyed, so a late notice for a slot already + // handed out again clears nothing of the successor's. + void NoteRecordDestroyed(MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return; + const SizeT slot = handle.Slot; + if (slot < m_viewLatch.size() && m_viewLatch[slot].RecordGen == handle.Gen) { + m_viewLatch[slot] = ViewLatch{}; + } + } + + // The validate point's FreshlyPrimed arm. It clears the PER-CONTEXT memo and NOTHING + // ELSE, and the absence is the rule rather than an oversight (D-J4): + // MGPipeApplierReset is a make-current, so it clears the three unit-set windows - whose + // mirrors are the suppressor slots the validate point invalidates beside this call - + // and it deliberately does NOT drop the object records. The sampler CSOs and the + // sampler views are object records, so re-publishing them here would move their Serial + // for nothing, and no P4a emitter may have a re-publication path. + // + // THE BOUND-SET REFERENCES DO GO, and that is not an exception to the rule above: they + // are pins on WORKING STATE, not object records. MGPipeApplierReset clears the three + // unit windows, so after this call no bind_sampler_states set names those CSOs any + // more and holding their references would pin cache entries no record is entitled to. + // The records themselves - the create_sampler_state the cache emitted - are untouched. + void Reset() { + MGPipeProgramOpaqueUnitsShared().Invalidate(); + MGPipeSamplerCsoCache& cache = MGPipeSamplerCsoCacheInstance(); + for (MGPipeHandle& held : m_stateRefs) { + cache.Release(held); + held = kMGPipeNullHandle; + } + } + + void ResetCounters() { m_viewSets = m_stateSets = m_viewCreates = 0; } + + // ---- what a unit case reads. No copy: the emitter builds INTO these and hands the + // applier the same pointers. ---- + const MGPSamplerViews& LastSamplerViews() const { return m_lastViews; } + const Array& LastBoundViews() const { return m_views; } + const MGPSamplerStates& LastSamplerStates() const { return m_lastStates; } + const Array& LastSamplerStateHandles() const { + return m_states; + } + const MGPSamplerView& LastCreatedView() const { return m_lastView; } + Uint64 ViewSetCount() const { return m_viewSets; } + Uint64 StateSetCount() const { return m_stateSets; } + Uint64 ViewCreateCount() const { return m_viewCreates; } + + private: + struct ViewLatch { + // "Does the applier hold a create_sampler_view record at this slot, for this + // generation, describing this shape". Lives exactly as long as the record does - + // see Reset() for why it is not cleared at a make-current. + Bool RecordLive = false; + Uint32 RecordGen = 0; + Uint64 ShapeVersion = 0; + Uint16 ParamsVersion = 0; + MGPipeHandle Texture = kMGPipeNullHandle; + }; + + static constexpr Uint32 Min(Uint32 a, Uint32 b) { return a < b ? a : b; } + + Array m_views{}; + Array m_states{}; + // THE REFERENCES bind_sampler_states IS HOLDING, one per unit (ID-17). Not the same + // array as m_states: that one is the record's tail and is rebuilt from live context + // state at every pass, while this one is the pin the applier's standing set is entitled + // to and it only moves when a unit's CSO really does. + Array m_stateRefs{}; + MGPSamplerViews m_lastViews{}; + MGPSamplerStates m_lastStates{}; + MGPSamplerView m_lastView{}; + + Vector m_viewLatch; + + Uint64 m_viewSets = 0; + Uint64 m_stateSets = 0; + Uint64 m_viewCreates = 0; + }; + + inline MGPipeSamplerEmitter& MGPipeSamplerEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason - and this one is named in the + // phase's own risk list: a new client singleton that held a frontend SharedPtr, or + // that had a destructor an exit handler could run into a torn-down pipe, is the + // exit-order UAF P3a closed. Heap-constructed and intentionally leaked at exit. + static MGPipeSamplerEmitter* emitter = new MGPipeSamplerEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h new file mode 100644 index 000000000..826594438 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SetHashSuppressor.h @@ -0,0 +1,134 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SetHashSuppressor.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// Coalescing rule 4 (ARCHITECTURE.md 5.4, P2 brief D11): every kVarTail set_* hashes the +// RESOLVED set on the client and does not emit when the hash has not moved. +// +// This is the carrier for the ~175 lines of debounce that move off the backends in P3b and +// P4b - Espryt's UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and +// Magma's equivalents all answer "is this set the same set as last time", and every one of +// them answers it against a shape the backend rediscovered. P2 lands the MECHANISM and ONE +// real consumer (SetVertexAttribDefaults) so the shape is pinned by a test rather than by a +// plan; the other six slots exist, are unit-tested, and are wired by the phase that moves +// the set they name. P3a wires the second, SetVertexBuffers. P4a wires SetSamplerViews, +// BindSamplerStates and SetShaderImages, and APPENDS an eighth slot, SetFramebufferState - +// which leaves only SetShaderBuffers and SetStreamOutputTargets unwired, both P4b's. +// +// A WIRED SLOT PUTS A REQUIREMENT ON ITS HASH, and SetVertexBuffers is where that first +// bites: the hash has to cover EVERY input the record carries, not only the set. Its +// baseInstance is DRAW state and moves without the buffer set moving, so a hash over the +// entries alone would suppress a record whose one changed field is the fetch shift and the +// server would keep the previous one. MG_Impl/Pipe/VertexInputEmit.h's +// MGPipeVertexBufferSetContentHash mixes Start, Count and BaseInstance in for exactly that +// reason, and VertexInputEmit's base-instance pair is the test that says so. +// +// A hash of 0 is reserved for "never emitted", so the first emission always goes out; a +// computed 0 is remapped to 1, which costs one collision in 2^64 an extra emission and +// never a missed one. +// +// Header-only for the same ownership reason as Tracker.h and CsoCache.h: the root +// CMakeLists.txt that would name a new .cpp belongs to package A and is frozen behind the +// p2/contract tag. +#if MOBILEGL_PIPE_PUSH +#include + +namespace MobileGL::MG_Pipe { + + // One slot per kVarTail set_* (ARCHITECTURE.md 5.1's call list), PLUS + // SetFramebufferState, which is not kVarTail at all: MGPFramebufferState carries a + // ContentHash for TWO jobs - the server's render-pass memo key and the client's emission + // suppressor - and the second one needs a slot here like any other, PLUS + // SetContextValues (P5c rv), also not kVarTail: one fixed-width POD whose whole-record + // hash is the "did any covered value move" answer. The enum is + // CLIENT-ONLY and is not a wire opcode, so appending before Count is safe. + enum class MGPipeSuppressorSlot : Uint32 { + SetVertexBuffers = 0, // P3a - wired, and its hash includes BaseInstance + // P4a - WIRED. The three unit sets' suppressors are not optional and were never a + // later phase's: MGPipeTypes.h makes the pattern mandatory for every kVarTail set_*, + // because GetTextureBindGeneration() bumps on a REDUNDANT rebind - MC 26.2 rebinds the + // same sampler at every texture-unit switch - so an unsuppressed set is a + // several-hundred-byte variable-length record per batch, which is the exact regression + // the design names. What P3b/P4b owns is the ~175-line BACKEND debounce these replace + // (UnitBindingsSnapshot / CaptureUnitBindings / UnitBindingsUnchanged and the two + // g_*SyncList tables); P4a wires the carrier, P3b/P4b deletes the backend copy. + SetSamplerViews, // P4a - wired (backend debounce deletion: P3b/P4b) + BindSamplerStates, // P4a - wired (backend debounce deletion: P3b/P4b) + SetShaderImages, // P4a - wired (backend debounce deletion: P3b/P4b) + // P5e (MG_Remote/CONTRACT-P5E.md §1, ruling 11): THREE SLOTS, ONE PER CLASS, not one + // for the call. set_shader_buffers is emitted per Class (Uniform / ShaderStorage / + // AtomicCounter) because the record's own Class field says which binding-point array + // it describes - so a single slot would make every emission of one class cancel the + // previous emission of another, and the shader-storage set would be suppressed as + // "unchanged" by a uniform set that happened to hash the same way. Three slots also + // keep the per-family fire tally and the A/B meaningful, which one keyed on + // (slot, Class) would not. + SetShaderBuffersUniform, // P5e (sb) + SetShaderBuffersShaderStorage, // P5e (sb) + SetShaderBuffersAtomicCounter, // P5e (sb) + SetStreamOutputTargets, // P4b + // P5e (CONTRACT-P5E.md §1): set_program_bindings, the post-link binding record. Not + // kVarTail-only - it has three tails - but the same rule applies: the emitter latches + // on (Cso, backendStateVersion, blockBindingVersion) and the whole-record hash is what + // says "nothing moved". + SetProgramBindings, // P5e (pg) + SetVertexAttribDefaults, // P2 - the one consumer that is wired + SetFramebufferState, // P4a - wired + // P5c rv (CONTRACT-P5C.md §5.3). NOT kVarTail either - the same shape as + // SetFramebufferState's note: MGPContextValues is one fixed-width POD, and the + // whole-record hash IS its "did any covered value move" answer (there is deliberately + // no dirty mask in the payload - a suppressed record means "nothing moved", never + // "field invalid"). + SetContextValues, // P5c rv - wired, split+transport only (PipeFill.cpp gates) + Count, + }; + + inline constexpr SizeT kMGPipeSuppressorSlotCount = static_cast(MGPipeSuppressorSlot::Count); + + class MGPipeSetHashSuppressor { + public: + // True when `contentHash` differs from what this slot last emitted, and LATCHES it. + // False means the resolved set has not moved and the call must not go out. + Bool ShouldEmit(MGPipeSuppressorSlot slot, Uint64 contentHash) { + const Uint64 latched = contentHash == 0 ? 1 : contentHash; + const SizeT index = static_cast(slot); + if (m_lastEmitted[index] == latched) return false; + m_lastEmitted[index] = latched; + return true; + } + + // A context change or a server reset: what the server has is no longer what this + // slot last emitted, so the next resolved set must go out whatever it hashes to. + void Invalidate(MGPipeSuppressorSlot slot) { m_lastEmitted[static_cast(slot)] = 0; } + + void InvalidateAll() { + for (SizeT i = 0; i < kMGPipeSuppressorSlotCount; ++i) m_lastEmitted[i] = 0; + } + + // 0 == "never emitted". Exposed for the unit test, which is what pins that the + // reserved value really is reserved. + Uint64 LastEmitted(MGPipeSuppressorSlot slot) const { + return m_lastEmitted[static_cast(slot)]; + } + + private: + Array m_lastEmitted{}; + }; + + // The monolith's one suppressor, beside the tracker and the CSO cache. + inline MGPipeSetHashSuppressor& MGPipeSetHashSuppressorInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason (MG_Impl/Pipe/Tracker.h): the + // rule covers every MGPipe process singleton, not only the ones on today's death + // paths. + static MGPipeSetHashSuppressor* suppressor = new MGPipeSetHashSuppressor(); + return *suppressor; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h b/MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h new file mode 100644 index 000000000..530fc6c4e --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/ShaderBufferEmit.h @@ -0,0 +1,277 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/ShaderBufferEmit.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 + +// The CLIENT side of set_shader_buffers - the INDEXED BUFFER BINDING POINTS (P5e package sb, +// MG_Remote/CONTRACT-P5E.md §5.6, rulings 10 and 11). The call has been in the catalogue since +// P4a with no emitter, no route and no applier; this file is the emitter half. +// +// ONE RECORD PER CLASS, THREE CLASSES. MGPShaderBuffers::Class names which binding-point array +// the record describes - Uniform=0, ShaderStorage=1, AtomicCounter=2 - so the three are three +// emissions through one route and one applier, not one record with three tails. That is also +// why the suppressor has THREE SLOTS (ruling 11): a single slot would let a uniform set cancel +// the previous storage set, and the shader-storage bindings would be suppressed as "unchanged" +// by a record that described something else entirely. +// +// XFB IS NOT ONE OF THEM (§5.7). The transform-feedback capture points are span-scoped state +// latched at glBeginTransformFeedback, and set_stream_output_targets carries a Generation this +// payload has no field for - so the catalogue's split between the two rows stays, XFB keeps its +// lockstep escalation, and dirty bit 17 fires for a family that emits nothing this phase. +// +// TWO INVARIANTS THAT MUST SURVIVE INTO THE BODY: +// 1. THE HIGH-WATER-ZERO EARLY-OUT. A class whose touched high-water mark is 0 emits nothing, +// BEFORE any hash and before any walk. Minecraft's touched SSBO and atomic-counter counts +// are both 0, so the whole family costs one integer read per class per validate point on +// the workload this phase exists for. +// 2. A BASE BINDING TRAVELS AS kMGPipeWholeBuffer AND NEVER AS A RESOLVED EXTENT (§5.6, +// table 0). glBindBufferBase does not freeze anything: GL resolves the range against the +// object's size at every USE, and BindingSlotRange1D::GetRange() reproduces that by asking +// the bound object. Resolving it HERE would freeze the size as of the emission, and under +// run-ahead a glBufferData issued between the emission and the apply would then bind the +// OLD extent - silently, with the right handle. So Offset/Size are copied only for an +// EXPLICIT range (glBindBufferRange), and a base binding says "whole buffer" and lets the +// server re-resolve against its own descriptor. +// +// THE WINDOW IS THE TOUCHED HIGH-WATER MARK (ruling 10), the same value the backend's own walk +// has used since P2 and the same value MGPContextValues already carries. A program-derived +// window would be narrower for the uniform class - Minecraft reaches UBOs through the program's +// block bindings, so a program binding block 0 at GL point 83 pays a 84-entry record - but that +// is a NARROWING and this package is a correctness landing; BRIEF-P5E §5 lists it as trailing. +// +// HEADER-ONLY, and the wired constant below is what switches the family on, for the ownership +// reason PipeFill.cpp states in full: that file belongs to the contract package for the whole +// phase, so the bit that turns an emitter on is a constant in the emitter's OWN header. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include // MGPipeMixShutter, the one shutter-mixing function +#include +#include +#include +#include +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + + // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the per-family + // constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives this + // emitter its body and no file is touched twice. + // + // IT MUST MOVE WITH MG_Backend/Init.cpp's CONSUMER BIT (ID-106). c0e deliberately withheld + // bit 13 from ConsumedSubsystemsFor because no emitter existed and withholding is the safe + // direction; the client's R-8 liveness gate (PipeFill.cpp's P5eFamilyIsLive) asks the server + // for THIS bit in particular, so a build that set the constant here and left the server's + // mask alone would emit nothing at all and look exactly like a build that had not landed. + // + // TURNING IT ON DOES NOT RETIRE A PULL. GetBufferBindingPoint is the family's Coverage.def + // emitted row and PipeFill.cpp's EmittedCallSuppliesTheWholeField answers FALSE for it: the + // field is four raw bases into the frontend's binding-point table and the record carries + // resolved {handle, offset, size} triples, so the residual fill keeps writing the mirror and + // the verify comparator keeps proving it. What retires the pull is the four Espryt consumers + // reading the applier's window instead, which is the other half of this package. + inline constexpr Uint64 kMGPipeWiredBufferBindingSubsystem = kMGPipeSubsystemBufferBindings; + + // The BufferTarget each wire class names. One table, because the class is a wire value and + // the target is a frontend enum, and a switch written at each of the four call sites is four + // chances to pair Uniform with ShaderStorage. + inline constexpr BufferTarget MGPipeBufferTargetForShaderBufferClass(Uint32 cls) { + switch (cls) { + case kMGPipeShaderBufferClassShaderStorage: return BufferTarget::ShaderStorage; + case kMGPipeShaderBufferClassAtomicCounter: return BufferTarget::AtomicCounter; + default: return BufferTarget::Uniform; + } + } + + // And the suppressor slot, for ruling 11's reason - one per class. + inline constexpr MGPipeSuppressorSlot MGPipeSuppressorSlotForShaderBufferClass(Uint32 cls) { + switch (cls) { + case kMGPipeShaderBufferClassShaderStorage: + return MGPipeSuppressorSlot::SetShaderBuffersShaderStorage; + case kMGPipeShaderBufferClassAtomicCounter: + return MGPipeSuppressorSlot::SetShaderBuffersAtomicCounter; + default: return MGPipeSuppressorSlot::SetShaderBuffersUniform; + } + } + + // D-G3's shape, the same one the three unit sets use: XXH64 over the tail with the header's + // own discriminators mixed in. Class is mixed BECAUSE the three classes share nothing but + // this function - two classes whose windows happen to hold the same ranges must not hash the + // same, or the per-class A/B tallies would be reading each other's traffic - and the mask + // words are mixed because they are a FIELD of the record that the entries alone do not + // determine (a uniform range and a storage range with the same handle differ only there). + inline Uint64 MGPipeShaderBufferSetContentHash(const MGPBufferRange* entries, Uint32 cls, + Uint32 start, Uint32 count, + const Uint32* writableMask) { + Uint64 hash = XXH64(entries, static_cast(count) * sizeof(MGPBufferRange), 0); + hash = MGPipeMixShutter(hash, cls); + hash = MGPipeMixShutter(hash, start); + hash = MGPipeMixShutter(hash, count); + for (Uint32 w = 0; w < kMGPipeShaderBufferWritableMaskWords; ++w) { + hash = MGPipeMixShutter(hash, writableMask[w]); + } + return hash; + } + + class MGPipeShaderBufferEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + + // Dirty bit 15 (NEW_CONST_BUFFERS) -> the uniform binding points. + Uint64 EmitConstBuffers(GLContext& ctx) { + return EmitClass(ctx, kMGPipeShaderBufferClassUniform); + } + + // Dirty bit 16 (NEW_SHADER_BUFFERS) -> the two WRITABLE classes, which share a bit + // because they share a shutter: a storage bind and a counter bind are both "the + // shader's writable binding points moved", and a workload that touches one without the + // other pays one suppressed record rather than a second dirty bit. + Uint64 EmitShaderBuffers(GLContext& ctx) { + return EmitClass(ctx, kMGPipeShaderBufferClassShaderStorage) + + EmitClass(ctx, kMGPipeShaderBufferClassAtomicCounter); + } + + // The validate point's FreshlyPrimed arm. A fresh context is a fresh set of binding + // points AND a fresh applier window (MGPipeApplierReset clears all three), so the + // emitter's mirrors go with them. The suppressor slots are invalidated beside this call + // by InvalidateAll(); without that the first emission after a make-current would be + // suppressed as unchanged and the server would draw against a cleared window. + void Reset() { + for (Uint32 cls = 0; cls < kMGPipeShaderBufferClassCount; ++cls) { + m_last[cls] = MGPShaderBuffers{}; + } + } + + void ResetCounters() { + for (Uint32 cls = 0; cls < kMGPipeShaderBufferClassCount; ++cls) m_emissions[cls] = 0; + } + + // What a unit case reads. The emitter hands m_entries straight to the route, so "what + // was emitted" costs no copy at all - VertexInputEmit.h's G6/G7 property. + const MGPShaderBuffers& LastHeader(Uint32 cls) const { + return m_last[cls < kMGPipeShaderBufferClassCount ? cls : 0]; + } + const Array& LastRanges() const { + return m_entries; + } + Uint64 EmissionCount(Uint32 cls) const { + return m_emissions[cls < kMGPipeShaderBufferClassCount ? cls : 0]; + } + + private: + Uint64 EmitClass(GLContext& ctx, Uint32 cls) { + const BufferTarget target = MGPipeBufferTargetForShaderBufferClass(cls); + // INVARIANT 1, and it is one integer read on every draw of every application that + // never binds an indexed buffer of this class. + const SizeT touched = ctx.GetTouchedBufferBindingPointCount(target); + if (touched == 0) return 0; + const Uint32 count = touched < kMGPipeMaxBufferBindingPoints + ? static_cast(touched) + : kMGPipeMaxBufferBindingPoints; + // NO CLIENT-SIDE CLAMP TO THE DEVICE's LIMIT (ruling 10). MobileGL advertises the + // GL 4.5 minimum of 84 uniform binding points while ES 3.2's is 72, and which of + // them the driver can actually hold is a SERVER question: the backend clamps to + // GL_MAX_UNIFORM_BUFFER_BINDINGS exactly as it does today, because a client reading + // a device capability would be answering it from the wrong side of the wire. + + // The writable mask is rebuilt from scratch for every emission: it is a property of + // the window as it stands now, not an accumulation, and a sticky one would keep + // claiming a point that has since been unbound. + Uint32 writableMask[kMGPipeShaderBufferWritableMaskWords] = {}; + const Bool classIsWritable = cls != kMGPipeShaderBufferClassUniform; + + for (Uint32 i = 0; i < count; ++i) { + const auto& point = ctx.GetBufferBindingPoint(target, i); + const auto& object = point.GetBoundObject(); + MGPBufferRange& entry = m_entries[i]; + entry = MGPBufferRange{}; + if (!object) { + // A null handle is "nothing bound at that point", which is what the server + // binds 0 for. Spelled by clearing the entry rather than by shortening the + // window: the window is the touched high-water mark and a hole in the + // middle of it is ordinary. + entry.Res = kMGPipeNullHandle; + continue; + } + entry.Res = MGPipeSlots().Acquire(MGPipeKind::Buffer, object->GetLifetimeId()); + // D-A3's sticky bind mask, ORed HERE for the reason VertexInputEmit.h ORs it at + // every draw: a buffer given its storage through glNamedBufferData and only ever + // bound with glBindBufferBase is bound to NOTHING at its resource emission, so + // the mask sampled there would never carry its UNIFORM / SHADER_STORAGE / + // ATOMIC_COUNTER bit. This is the resolution site, and it is sticky, so one + // validate point is enough for the rest of the buffer's life. + MGPipeResourceTrackerInstance().NoteBoundAs(entry.Res, target); + // INVARIANT 2. HasExplicitRange() is exactly "this point was bound with + // glBindBufferRange": BindingSlotRange1D keeps the flag precisely so GetRange() + // can re-resolve a BASE binding against the object every time it is asked, and + // that re-resolution is what must happen on the SERVER rather than here. + if (point.HasExplicitRange()) { + const auto range = point.GetRange(); + entry.Offset = static_cast(range.start); + entry.Size = static_cast(range.end - range.start); + } else { + entry.Offset = 0; + entry.Size = kMGPipeWholeBuffer; + } + // WHAT REPLACES THE BACKEND's GPU-WRITE WALK. Every bound storage-buffer and + // atomic-counter point is marked writable, which is exactly the set + // MarkShaderStorageBuffersGpuWritten and SyncAtomicCounterBuffers used to walk + // the frontend for - conservative in the same direction and for the same reason + // (GpuWritePending.h: an over-approximate set costs a readback, an under- + // approximate one reads a stale shadow and says nothing). The uniform class + // never sets a bit: a UBO is read-only to the shader by definition. + if (classIsWritable) MGPipeShaderBufferMaskSet(writableMask, i); + } + + const Uint64 hash = + MGPipeShaderBufferSetContentHash(m_entries.data(), cls, 0, count, writableMask); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit( + MGPipeSuppressorSlotForShaderBufferClass(cls), hash)) { + return 0; + } + MGPShaderBuffers& header = m_last[cls]; + header = MGPShaderBuffers{}; + header.Class = cls; + header.Start = 0; + header.Count = count; + for (Uint32 w = 0; w < kMGPipeShaderBufferWritableMaskWords; ++w) { + header.WritableMask[w] = writableMask[w]; + } + // HostSpanCount IS 0 ALWAYS on Espryt, and that is a ruling rather than an omission: + // the second var-tail exists for kCapNeedsHostUboBytes (Magma's named-UBO ring) and + // that bit is 0 for the whole of P5 (CONTRACT-P5.md table 0). The codec's host-span + // honesty pass is the guard that says so out loud if a backend ever publishes it. + header.HostSpanCount = 0; + header.ContentHash = hash; + MGPipeRouteSetShaderBuffers(header, m_entries.data()); + ++m_emissions[cls]; + return sizeof(MGPShaderBuffers) + static_cast(count) * sizeof(MGPBufferRange); + } + + // ONE entry buffer for all three classes, not three. A class is built and routed before + // the next one is built (EmitShaderBuffers above calls EmitClass twice in sequence and + // the route copies or stages the tail synchronously in both arms), so three would be + // three times 84 * 24 bytes of resident staging for no reader. + Array m_entries{}; + MGPShaderBuffers m_last[kMGPipeShaderBufferClassCount]{}; + Uint64 m_emissions[kMGPipeShaderBufferClassCount] = {0, 0, 0}; + }; + + inline MGPipeShaderBufferEmitter& MGPipeShaderBufferEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason; heap-constructed and + // intentionally leaked at exit, like every other MGPipe process singleton. + static MGPipeShaderBufferEmitter* emitter = new MGPipeShaderBufferEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp new file mode 100644 index 000000000..f2b749521 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.cpp @@ -0,0 +1,436 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// SlotAllocator.h. Compiled only under MOBILEGL_PIPE_PUSH. +#include + +#if MOBILEGL_BUILD_DISAGGREGATED +#include +// P5e (id): the guard's exemption is keyed on whether the record being applied is BARRIERED +// (CONTRACT-P5E §4.4), and MGPipeApplierCurrentRecordIsBarriered() is where ApplyOne stamps +// that. Declarations only - this file names no applier state. +#include +#include +#include + +#include +#endif + +namespace MobileGL::MG_Pipe { +#if MOBILEGL_BUILD_DISAGGREGATED + Bool MGPipeApplierIsUnbarrieredApply() { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return false; + if (!MG_Remote::Server::ServerLoop::OnApplyThread()) return false; + return !MGPipeApplierCurrentRecordIsBarriered(); + } + + void MGPipeRefuseAllocatorFromApplyThread(const char* entry) { + if (MG_Config::Transport == MG_Config::TransportMode::Monolith) return; + if (!MG_Remote::Server::ServerLoop::OnApplyThread()) return; + // CONTRACT-P5E §4.4 AMENDS CONTRACT-P5C §3.1 HERE, AND IT IS THE WHOLE OF WHAT id + // CHANGES ABOUT THIS FUNCTION: a named exemption is a debt the CLIENT'S WAIT pays for. + // Behind a barriered record the client is parked in WaitForApplied and its allocator + // is not moving, so a read-only probe is stale-free; behind an unbarriered one it is + // running ahead, and the same probe reads a free list and a lifetimeId -> slot map the + // client is concurrently mutating. So the two scopes below exempt nothing at all once + // the record is unbarriered - regardless of MOBILEGL_IPC_STRICT_ERRORS, because the + // value would be wrong by construction and there is no "count it" arm for that + // (CONTRACT-P5E, rule F). + // + // Every record is barriered until ra lands the wait rule, so this reads exactly as it + // did at 2fde7034 for the whole of this phase. + if (MGPipeApplierCurrentRecordIsBarriered()) { + // Ruling 12: Magma's four P7 debts, and ONLY on a DirectVulkan server. The key is + // on the backend kind rather than on the site because the scope is a class anyone + // can construct, and an Espryt probe borrowing Magma's exemption is exactly the + // hole P5e is closing. + if (MagmaP7AllocatorDebtScope::ActiveOnApplyThread() && + MG_Config::ActiveBackendType == BackendType::DirectVulkan) { + return; + } + // The G6 frontend-keyed registry family: the barriered-row sites the per-family + // packages have not carried a handle to yet (CONTRACT-P5E §4.4). + if (MGPipeFrontendKeyedRegistryScope::ActiveOnApplyThread()) return; + } + MGLOG_F("MGPipe: Fatal{RoleViolation, \"MGPipeSlots\"} - the apply thread called " + "MGPipeSlots().%s. With an active transport the client slot allocator is " + "client-only memory (CONTRACT-P5C §3.1, rule E; CONTRACT-P5E §4.4): a handle " + "arrives already minted in a record, and a server that resolves or mints one " + "off a frontend object's lifetime id is reading memory that will not exist on " + "its side of a real split. A named exemption scope admits it only while the " + "record being applied is BARRIERED (barriered=%d) and, for Magma's P7 debt, " + "only on a DirectVulkan server", + entry, MGPipeApplierCurrentRecordIsBarriered() ? 1 : 0); + std::abort(); + } + + // P5e (id): the non-allocator half of the same rule. SlotTables.h's state note, its reader + // and ForEachLive's weak-reference walk read server memory KEYED BY FRONTEND IDENTITY and + // hand a frontend SharedPtr back, which is the same violation one step removed - no + // allocator call, so the guard above never sees it. + void MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply(const char* entry) { + if (!MGPipeApplierIsUnbarrieredApply()) return; + MGLOG_F("MGPipe: Fatal{RoleViolation, \"MGPipeSlots\"} - the apply thread reached " + "BackendSlotTable::%s while applying an UNBARRIERED record. The frontend-keyed " + "half of the twin table is monolith glue (CONTRACT-P5E §4.1, §5.8): it answers " + "from a frontend object the server has no wait pinning, so the SharedPtr it " + "would hand back may already be the client's next object. Resolve the twin " + "from the handle the record carried instead", + entry); + std::abort(); + } + + namespace { + // NOT thread_local any more, and not atomic either (P5d round 3, package D). + // + // WHY THERE IS NO DATA RACE. Both depths have exactly ONE reader in the whole tree: + // MGPipeRefuseAllocatorFromApplyThread above, whose first two lines return unless the + // transport is active AND ServerLoop::OnApplyThread() is true. So the only thread whose + // depth could ever change an answer is the apply thread - and the four scope bodies + // below now increment and decrement ONLY when OnApplyThread() says so. That makes the + // apply thread the single writer and the single reader of both counters; no other + // thread touches them, and a monolith process never even reads them (the guard's first + // line returns on TransportMode::Monolith and there is no apply thread to make the + // second true). Single-writer-single-reader on one thread needs no lock, no atomic and + // no TLS. + // + // WHY IT WAS WORTH DOING. A shared-library thread_local costs an __emutls_get_address + // call per access. On the MONOLITH's GL thread that symbol is the TOP entry of the + // profile at 7.7%, and these two scopes' ctor/dtor are 32.7% of its samples + // (MGPipeFrontendKeyedRegistryScope 18.75 ctor + 13.95 dtor, the scope P5e renamed + // MagmaP7AllocatorDebtScope 8.4); on the split's apply thread emutls is 7.4%. The + // measurement predates the rename and the numbers are quoted as measured. The scope is + // constructed at ~20 + // sites in DirectGLES.cpp, several inside StateBackendObjectRegistry::HandleOf, which + // is per-draw. The cost on the GL thread is now one inlined predicate per end. + // + // The query's meaning ON THE APPLY THREAD is unchanged, which is the only meaning + // the guard reads. Off it the answer is now always false, and the query is named + // ActiveOnApplyThread() rather than Active() so that a reader who needs the other + // meaning cannot ask for it by accident: whoever wants "is a scope open on THIS + // thread" has to add it, and move the counting back, rather than read a false that + // looks like an answer. + Uint32 g_magmaP7AllocatorDebtScopeDepth = 0; + } + + MagmaP7AllocatorDebtScope::MagmaP7AllocatorDebtScope() + // Decided ONCE and remembered: the destructor must undo exactly what the constructor + // did, and re-asking the predicate would leak a count across a scope that straddled + // the apply thread's exit block. + : m_counted(MG_Remote::Server::ServerLoop::OnApplyThread()) { + if (m_counted) ++g_magmaP7AllocatorDebtScopeDepth; + } + + MagmaP7AllocatorDebtScope::~MagmaP7AllocatorDebtScope() { + if (m_counted) --g_magmaP7AllocatorDebtScopeDepth; + } + + // The DEPTH is not backend-keyed and deliberately so: the backend kind is read by the + // GUARD, once, at the moment it decides. Counting only on DirectVulkan would make the + // depth's meaning depend on a global that a test can move between the constructor and the + // destructor, which is the m_counted bug one level out. + Bool MagmaP7AllocatorDebtScope::ActiveOnApplyThread() { + return g_magmaP7AllocatorDebtScopeDepth != 0; + } + + namespace { + // Same counter, same argument, same single reader - see the block above. + Uint32 g_frontendKeyedRegistryScopeDepth = 0; + } + + MGPipeFrontendKeyedRegistryScope::MGPipeFrontendKeyedRegistryScope() + : m_counted(MG_Remote::Server::ServerLoop::OnApplyThread()) { + if (m_counted) ++g_frontendKeyedRegistryScopeDepth; + } + + MGPipeFrontendKeyedRegistryScope::~MGPipeFrontendKeyedRegistryScope() { + if (m_counted) --g_frontendKeyedRegistryScopeDepth; + } + + Bool MGPipeFrontendKeyedRegistryScope::ActiveOnApplyThread() { + return g_frontendKeyedRegistryScopeDepth != 0; + } +#endif + + namespace { + // The ShaderCso band the ordinary allocator must never enter: the top 1/16 of the + // ShaderCso slot space is reserved for PROGRAM PIPELINE COMPOSITES, which are minted + // client-side out of the stage programs bound to a pipeline object. Reserving a band + // rather than a flag keeps the composite resolver's lifetime bookkeeping out of here + // (MGPipeHandles.h, ARCHITECTURE.md 5.6.3). + Bool SlotIsAllocatable(MGPipeKind kind, Uint32 slot) { + if (slot < kMGPipeFirstAllocatableSlot) return false; + if (kind != MGPipeKind::ShaderCso) return true; + return slot < kMGPipeShaderCsoCompositeSlotBase; + } + } // namespace + + MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) { + const SizeT index = static_cast(kind); + MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index); + return m_kinds[index < kKindCount ? index : 0]; + } + + const MGPipeSlotAllocator::KindState& MGPipeSlotAllocator::StateOf(MGPipeKind kind) const { + const SizeT index = static_cast(kind); + MOBILEGL_ASSERT(index < kKindCount, "MGPipeKind %zu out of range", index); + return m_kinds[index < kKindCount ? index : 0]; + } + + MGPipeSlotAllocator::SlotState* MGPipeSlotAllocator::EntryOf(KindState& state, MGPipeKind kind, + Uint32 slot) { + if (kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(slot)) { + const SizeT index = slot - kMGPipeShaderCsoCompositeSlotBase; + if (index >= state.BandSlots.size()) return nullptr; + return &state.BandSlots[index]; + } + if (slot >= state.Slots.size()) return nullptr; + return &state.Slots[slot]; + } + + const MGPipeSlotAllocator::SlotState* + MGPipeSlotAllocator::EntryOf(const KindState& state, MGPipeKind kind, Uint32 slot) { + return EntryOf(const_cast(state), kind, slot); + } + + MGPipeHandle MGPipeSlotAllocator::Allocate(MGPipeKind kind) { + KindState& state = StateOf(kind); + if (state.Slots.empty()) { + // Slot 0 exists so the vector is slot-indexed, and is never handed out. + state.Slots.resize(kMGPipeFirstAllocatableSlot); + } + + Uint32 slot = 0; + Bool reused = false; + while (!state.FreeList.empty()) { + const Uint32 candidate = state.FreeList.back(); + state.FreeList.pop_back(); + if (!SlotIsAllocatable(kind, candidate)) continue; + slot = candidate; + reused = true; + break; + } + + if (!reused) { + slot = static_cast(state.Slots.size()); + MOBILEGL_ASSERT(SlotIsAllocatable(kind, slot), + "MGPipe slot space of kind %u is exhausted at slot %u", + static_cast(kind), slot); + if (!SlotIsAllocatable(kind, slot)) return kMGPipeNullHandle; + state.Slots.emplace_back(); + } + + SlotState& entry = state.Slots[slot]; + if (entry.EverHandedOut) { + // The one place Gen may move. 2^32 recycles of ONE slot is ~50 days of continuous + // churn at one recycle per frame at 1000 fps, which is why the bound is asserted + // in a debug allocator rather than defended in release. + MOBILEGL_ASSERT(entry.Gen != ~Uint32{0}, + "MGPipe handle generation wrapped on kind %u slot %u; {slot, gen} is " + "no longer unique", + static_cast(kind), slot); + ++entry.Gen; + } + entry.EverHandedOut = true; + entry.Live = true; + entry.LifetimeId = 0; + ++state.LiveCount; + return MGPipeHandle{slot, entry.Gen}; + } + + MGPipeHandle MGPipeSlotAllocator::AllocateFor(MGPipeKind kind, Uint64 lifetimeId) { + const MGPipeHandle handle = Allocate(kind); + if (MGPipeHandleIsNull(handle)) return handle; + KindState& state = StateOf(kind); + state.Slots[handle.Slot].LifetimeId = lifetimeId; + if (lifetimeId != 0) { + MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(), + "lifetime id %llu already owns a slot of kind %u", + static_cast(lifetimeId), static_cast(kind)); + state.ByLifetimeId[lifetimeId] = handle.Slot; + } + return handle; + } + + MGPipeHandle MGPipeSlotAllocator::AllocateComposite(Uint64 lifetimeId) { + // P4a, D-H7. The mirror image of Allocate() above, restricted to the band that one + // refuses, and kept in a table of its own so both spaces stay DENSE: the band's base + // is 983040, and minting one composite into the slot-indexed vector would allocate + // ~23 MB of SlotState for a single program pipeline. + KindState& state = StateOf(MGPipeKind::ShaderCso); + + Uint32 slot = 0; + Bool reused = false; + if (!state.BandFreeList.empty()) { + slot = state.BandFreeList.back(); + state.BandFreeList.pop_back(); + reused = true; + } + + if (!reused) { + const SizeT next = kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size(); + slot = static_cast(next); + // The band's own exhaustion assert, mirroring Allocate()'s: a composite that + // cannot be minted is a NAMED failure, not a silent fall-through into the ordinary + // program slots, which is exactly what reserving a band rather than setting a flag + // buys. + MOBILEGL_ASSERT(next < kMGPipeShaderCsoSlotLimit, + "the MGPipe ShaderCso COMPOSITE band is exhausted at slot %zu; a " + "program-pipeline composite cannot be minted and must not take an " + "ordinary program's slot", + next); + if (next >= kMGPipeShaderCsoSlotLimit) return kMGPipeNullHandle; + state.BandSlots.emplace_back(); + } + + SlotState* entry = EntryOf(state, MGPipeKind::ShaderCso, slot); + if (entry == nullptr) return kMGPipeNullHandle; + if (entry->EverHandedOut) { + MOBILEGL_ASSERT(entry->Gen != ~Uint32{0}, + "MGPipe handle generation wrapped on the ShaderCso composite band, " + "slot %u; {slot, gen} is no longer unique", + slot); + ++entry->Gen; + } + entry->EverHandedOut = true; + entry->Live = true; + entry->LifetimeId = lifetimeId; + ++state.LiveCount; + // The band's share of LiveCount, so CompositeLiveCount() can answer without a walk. + ++state.BandLiveCount; + if (lifetimeId != 0) { + MOBILEGL_ASSERT(state.ByLifetimeId.find(lifetimeId) == state.ByLifetimeId.end(), + "lifetime id %llu already owns a ShaderCso slot", + static_cast(lifetimeId)); + state.ByLifetimeId[lifetimeId] = slot; + } + return MGPipeHandle{slot, entry->Gen}; + } + + MGPipeHandle MGPipeSlotAllocator::FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const { +#if MOBILEGL_BUILD_DISAGGREGATED + MGPipeRefuseAllocatorFromApplyThread("FindByLifetimeId"); +#endif + if (lifetimeId == 0) return kMGPipeNullHandle; + const KindState& state = StateOf(kind); + const auto it = state.ByLifetimeId.find(lifetimeId); + if (it == state.ByLifetimeId.end()) return kMGPipeNullHandle; + const SlotState* entry = EntryOf(state, kind, it->second); + if (entry == nullptr || !entry->Live) return kMGPipeNullHandle; + return MGPipeHandle{it->second, entry->Gen}; + } + + MGPipeHandle MGPipeSlotAllocator::Acquire(MGPipeKind kind, Uint64 lifetimeId) { +#if MOBILEGL_BUILD_DISAGGREGATED + MGPipeRefuseAllocatorFromApplyThread("Acquire"); +#endif + const MGPipeHandle existing = FindByLifetimeId(kind, lifetimeId); + if (!MGPipeHandleIsNull(existing)) return existing; + return AllocateFor(kind, lifetimeId); + } + + void MGPipeSlotAllocator::Free(MGPipeKind kind, MGPipeHandle handle) { +#if MOBILEGL_BUILD_DISAGGREGATED + MGPipeRefuseAllocatorFromApplyThread("Free"); +#endif + KindState& state = StateOf(kind); + SlotState* entry = EntryOf(state, kind, handle.Slot); + if (entry == nullptr) return; + // A stale handle must not free the slot its successor now owns - that is the whole + // reason the generation is in the key. It is also what makes the SECOND of a + // composite's two independent release paths a proven no-op. + if (!entry->Live || entry->Gen != handle.Gen) return; + if (entry->LifetimeId != 0) { + const auto it = state.ByLifetimeId.find(entry->LifetimeId); + if (it != state.ByLifetimeId.end() && it->second == handle.Slot) { + state.ByLifetimeId.erase(it); + } + } + entry->Live = false; + entry->LifetimeId = 0; + --state.LiveCount; + if (kind == MGPipeKind::ShaderCso && MGPipeIsCompositeShaderSlot(handle.Slot)) { + --state.BandLiveCount; + state.BandFreeList.push_back(handle.Slot); + } else { + state.FreeList.push_back(handle.Slot); + } + } + + Bool MGPipeSlotAllocator::IsLive(MGPipeKind kind, MGPipeHandle handle) const { + const SlotState* entry = EntryOf(StateOf(kind), kind, handle.Slot); + return entry != nullptr && entry->Live && entry->Gen == handle.Gen; + } + + Uint32 MGPipeSlotAllocator::GenOfSlot(MGPipeKind kind, Uint32 slot) const { + const SlotState* entry = EntryOf(StateOf(kind), kind, slot); + return entry != nullptr ? entry->Gen : 0; + } + + Uint64 MGPipeSlotAllocator::LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const { + const SlotState* entry = EntryOf(StateOf(kind), kind, slot); + return entry != nullptr ? entry->LifetimeId : 0; + } + + Uint32 MGPipeSlotAllocator::HighWater(MGPipeKind kind) const { + // THE ORDINARY SPACE ONLY, and the band is reported by CompositeHighWater() below. + // Folding the two would pin this at ~983k from the first composite mint onward and + // take the ordinary space's "the high-water mark did not move" assertion away for the + // rest of the process - the assertion that catches a dense table that never shrinks, + // which is the leak shape this allocator exists to make visible. Two spaces, two + // numbers, two real assertions. See SlotAllocator.h. + return static_cast(StateOf(kind).Slots.size()); + } + + Uint32 MGPipeSlotAllocator::CompositeHighWater() const { + const KindState& state = StateOf(MGPipeKind::ShaderCso); + // One past the highest composite slot ever handed out; exactly the base when none ever + // was, so the number is monotone from the first mint and a LEAKED COMPOSITE MOVES IT. + return static_cast(kMGPipeShaderCsoCompositeSlotBase + state.BandSlots.size()); + } + + Uint32 MGPipeSlotAllocator::LiveCount(MGPipeKind kind) const { return StateOf(kind).LiveCount; } + + Uint32 MGPipeSlotAllocator::CompositeLiveCount() const { + return StateOf(MGPipeKind::ShaderCso).BandLiveCount; + } + + Uint32 MGPipeSlotAllocator::FreeCount(MGPipeKind kind) const { + const KindState& state = StateOf(kind); + return static_cast(state.FreeList.size() + state.BandFreeList.size()); + } + + Uint32 MGPipeSlotAllocator::CompositeFreeCount() const { + return static_cast(StateOf(MGPipeKind::ShaderCso).BandFreeList.size()); + } + + void MGPipeSlotAllocator::Reset() { + for (KindState& state : m_kinds) { + state.Slots.clear(); + state.FreeList.clear(); + state.BandSlots.clear(); + state.BandFreeList.clear(); + state.ByLifetimeId.clear(); + state.LiveCount = 0; + state.BandLiveCount = 0; + } + } + + MGPipeSlotAllocator& MGPipeSlots() { + // NEVER DESTROYED, deliberately (one allocation for the life of the process). A + // frontend object's destructor reaches this allocator - ~BufferObject through + // MGPipeEmitResourceDestroyAndFree, ~VertexArrayObject through the death notice - and + // MG_Backend/MGPipe/PipeInputs.h's gPipeInputs holds SharedPtrs to those objects at + // namespace scope, so they are destroyed by __run_exit_handlers AFTER this + // function-local static would have been. A destroyed allocator then answers + // FindByLifetimeId out of a freed hash table and Free() writes into freed vectors - + // an exit-time heap corruption whose fatality depends only on the allocator's layout. + static MGPipeSlotAllocator* allocator = new MGPipeSlotAllocator(); + return *allocator; + } +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/SlotAllocator.h b/MobileGL/MG_Impl/Pipe/SlotAllocator.h new file mode 100644 index 000000000..8c028ae1f --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/SlotAllocator.h @@ -0,0 +1,284 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/SlotAllocator.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +#include + +// The CLIENT's slot allocator: the thing that mints every MGPipeHandle in the system +// (ARCHITECTURE.md 4.2 - no create_* call in the catalogue returns a server-cast handle, +// which is what lets the whole catalogue be remoted with zero creation round trips). +// +// Per kind: a free list plus a high-water mark, so slots stay DENSE and the server's object +// table is an array rather than a hash map. It has nothing to do with MG_State's +// IndexGenerator - that container's LIFO GL-name reuse is the very problem {slot, gen} +// exists to close, and the whole point of the identity is that an ABA on the GL name, on +// the heap address or on the lifetime id cannot reproduce a handle. +// +// Gen increments ONLY when a slot is reused, never on a respecify: a glBufferData on a live +// buffer keeps the same {slot, gen}, because the object is the same object. Two generations +// exist in the design and they are strictly separate - this is the client's answer to "is +// this still the same GL object"; MGGen is the server's epoch for "did I recast my driver +// object", and no MGPipe call may require the client to know it. +// +// The lifetimeId -> slot map is what keeps a GL NAME out of every key (ARCHITECTURE.md 4.2): +// the frontend object's lifetime id is the client's own identity for it, so the backend key +// is the handle and the frontend key is the lifetime id, and neither is a recyclable name. +// +// Lives in MG_Impl (the client side, unrestricted) and is compiled only under +// MOBILEGL_PIPE_PUSH. It is in the P2 CONTRACT commit rather than in a Track H package +// because both Track H slices - Espryt 0b and Magma subsystem 4 - key off it. +namespace MobileGL::MG_Pipe { + + class MGPipeSlotAllocator { + public: + static constexpr SizeT kKindCount = static_cast(MGPipeKind::KindCount); + + // A fresh {slot, gen} of this kind, from the free list if one is waiting and from the + // high-water mark otherwise. Never returns slot 0 (reserved: null, and the default + // framebuffer for kind Framebuffer), and never returns a ShaderCso slot inside the + // composite band, which the program-pipeline resolver mints out of separately. + MGPipeHandle Allocate(MGPipeKind kind); + // Allocate and remember `lifetimeId` as this handle's frontend identity. + MGPipeHandle AllocateFor(MGPipeKind kind, Uint64 lifetimeId); + + // P4a, D-H7: THE ONE ENTRY POINT INTO THE ShaderCso COMPOSITE BAND, and the only one + // there will ever be. Allocate() above refuses that band on purpose, so a program + // pipeline's flattened composite - minted client-side from the stage programs bound to + // the pipeline object, and indistinguishable from an ordinary program to the server - + // needs a door of its own rather than a flag on the handle. The kind is implied: only + // ShaderCso has a band. + // + // It behaves exactly like AllocateFor in every other respect (free list first, then + // the band's own high-water mark; Gen moves only on reuse; the lifetimeId -> slot map + // is written) and it carries the band's own exhaustion assert, so exhausting the + // composite space is a NAMED Fatal rather than silent slot theft from ordinary + // programs. Returns kMGPipeNullHandle when the band is full. + // + // Freed through the ordinary Free(MGPipeKind::ShaderCso, handle): a composite's slot + // has two independent release paths - the pipeline cache's LRU eviction and the + // composite ProgramObject's own destructor - and Free refusing a slot that is not live + // at that generation is what makes the second one a proven no-op. + MGPipeHandle AllocateComposite(Uint64 lifetimeId); + // The handle a lifetime id was allocated for, or kMGPipeNullHandle. A recycled heap + // address does NOT reproduce a mapping: MG_State hands out a fresh lifetime id per + // object, so the map key is unique for the life of the process. + MGPipeHandle FindByLifetimeId(MGPipeKind kind, Uint64 lifetimeId) const; + // FindByLifetimeId, then AllocateFor when it misses. The ordinary client path. + MGPipeHandle Acquire(MGPipeKind kind, Uint64 lifetimeId); + + // Returns the slot to the free list. The Gen bump happens on the NEXT handout of that + // slot, not here, so a handle that is freed twice cannot skip a generation and the + // "gen moves only on reuse" contract holds for an object that is never reused. + void Free(MGPipeKind kind, MGPipeHandle handle); + + Bool IsLive(MGPipeKind kind, MGPipeHandle handle) const; + // 0 for a slot that was never handed out; the generation of the LAST handout + // otherwise, live or not. + Uint32 GenOfSlot(MGPipeKind kind, Uint32 slot) const; + Uint64 LifetimeIdOfSlot(MGPipeKind kind, Uint32 slot) const; + // One past the highest ORDINARY slot ever handed out of this kind. For every kind but + // ShaderCso that is the whole story; for ShaderCso the composite band is a second, + // separately dense space and CompositeHighWater() below answers it. + // + // THE TWO SPACES ARE REPORTED SEPARATELY, and that is the point rather than a detail. + // Folding the band into this number pins it at ~983k from the first composite mint + // onward, and every later assertion of the "the high-water mark did not move over N + // churn rounds" shape - the one that catches a dense table that never shrinks, which + // is the ~1.3 KB-per-record leak C-1 produced - becomes vacuously true for ordinary + // ShaderCso slots for the rest of the process. A leak case per space is two real + // assertions; one merged number is one real assertion and one that cannot go red. + // + // It is also NOT a table size for kind ShaderCso even now: the band is sparse against + // the ordinary space by design, so a consumer indexing by slot must test + // MGPipeIsCompositeShaderSlot(slot) first and keep the band in a table of its own, + // exactly as this allocator does. + Uint32 HighWater(MGPipeKind kind) const; + // One past the highest COMPOSITE slot ever handed out, i.e. + // kMGPipeShaderCsoCompositeSlotBase + (band slots ever handed out), and exactly the + // base when none ever was. Kind ShaderCso is the only kind with a band, so it is + // implied - as it is for AllocateComposite. A LEAKED COMPOSITE MOVES THIS and moves + // nothing else, which is what the composite's own leak case asserts on. + Uint32 CompositeHighWater() const; + // Live slots of this kind, ORDINARY AND COMPOSITE TOGETHER for ShaderCso: a live + // composite is a live ShaderCso, the applier's two record tables are one object class, + // and a caller asking "how many shader CSOs does this client hold" wants both. The + // band's own count is CompositeLiveCount(); the ordinary space's is the difference. + Uint32 LiveCount(MGPipeKind kind) const; + Uint32 CompositeLiveCount() const; + // Slots waiting on a free list. Also BOTH SPACES for ShaderCso, for LiveCount's + // reason and with the same caveat: a caller that needs to know WHICH space a slot went + // back to reads CompositeFreeCount() and subtracts. + Uint32 FreeCount(MGPipeKind kind) const; + Uint32 CompositeFreeCount() const; + + // Context teardown / server reset / a unit test's fixture. + void Reset(); + + private: + struct SlotState { + Uint32 Gen = 0; + Bool Live = false; + Bool EverHandedOut = false; + Uint64 LifetimeId = 0; + }; + + struct KindState { + // Indexed by slot; [0] is the reserved slot and is never live. + Vector Slots; + Vector FreeList; + // P4a: the ShaderCso COMPOSITE band, indexed by (slot - the band's base) and + // EMPTY for every other kind. A SECOND VECTOR RATHER THAN MORE OF THE FIRST, and + // it is not a micro-optimisation: the band starts at 983040, so minting one + // composite into the slot-indexed vector above would allocate ~983k SlotStates - + // ~23 MB - for a single program pipeline, and a consumer that sized a table off + // HighWater would pay the same shape again with a far bigger record. Both spaces + // stay dense against their own high-water mark, which is the property this + // allocator exists to give the server. + Vector BandSlots; + Vector BandFreeList; + UnorderedMap ByLifetimeId; + Uint32 LiveCount = 0; + // The band's share of LiveCount above, so the two spaces can be reported apart + // without walking either table. Always 0 for every kind but ShaderCso. + Uint32 BandLiveCount = 0; + }; + + KindState& StateOf(MGPipeKind kind); + const KindState& StateOf(MGPipeKind kind) const; + // The SlotState a (kind, slot) names, in whichever of the two vectors holds it, or + // null when the slot has never been handed out. One resolver, so a caller that forgets + // the band cannot exist. + static SlotState* EntryOf(KindState& state, MGPipeKind kind, Uint32 slot); + static const SlotState* EntryOf(const KindState& state, MGPipeKind kind, Uint32 slot); + + Array m_kinds{}; + }; + + // The monolith's one client allocator. Under split there is one per client context. + MGPipeSlotAllocator& MGPipeSlots(); + +#if MOBILEGL_BUILD_DISAGGREGATED + // P5c (hd, CONTRACT-P5C §3.1 / §6 layer 1): with an active transport this allocator is a + // CLIENT-only surface. Acquire, FindByLifetimeId and Free called from the apply thread - + // i.e. a server that resolves or mints handles off a frontend object's lifetime id (T2), + // which is memory that will not exist on its side of a real split - are + // Fatal{RoleViolation, "MGPipeSlots"}. Compiled out entirely outside split builds, so the + // pull build's bytes do not move (G1). + void MGPipeRefuseAllocatorFromApplyThread(const char* entry); + + // ---- P5e (id, CONTRACT-P5E §4.4): THE CONDITION UNDER WHICH A NAMED SCOPE EXEMPTS ----- + // + // "This thread is applying a record the client is NOT parked behind." Both scopes below + // exempt a probe only while it is FALSE, because the client's wait is the whole of what + // makes a frontend read from the apply thread safe: behind a barriered record the client + // is blocked in WaitForApplied and its memory is stable; behind an unbarriered one it is + // running ahead and the same read is torn or stale BY CONSTRUCTION, which is why rule F + // has no "count it" arm and this answer does not consult MOBILEGL_IPC_STRICT_ERRORS. + // + // False in a monolith process and on any thread but the apply thread, so a GL-thread + // caller is never refused by it. + Bool MGPipeApplierIsUnbarrieredApply(); + + // The refusal for the frontend-keyed surfaces that do NOT touch the allocator and so are + // not covered by MGPipeRefuseAllocatorFromApplyThread: the state note, its reader, and + // ForEachLive's weak-reference walk (SlotTables.h). They survive P5e as MONOLITH GLUE + // (CONTRACT-P5E §5.8) and this is what keeps that claim honest - reaching one of them from + // an unbarriered apply is Fatal{RoleViolation, "MGPipeSlots"} naming the member, not a + // silently stale SharedPtr. Same name in the refusal as the allocator guard's on purpose: + // it is one surface, "server memory keyed by frontend identity", and a reader chasing the + // abort should land on the same rule either way. + void MGPipeRefuseFrontendKeyedRegistryFromUnbarrieredApply(const char* entry); + + // The NAMED EXEMPTION to the rule above (CONTRACT-P5C §3.1, as amended by CONTRACT-P5E + // §4.4): the family of sites whose handle-carrying records the client does not EMIT yet. + // set_shader_buffers and set_stream_output_targets exist in the catalogue but are P4b/sb's + // to emit (SetHashSuppressor.h says so), so the buffer binding-point ensures and the + // GPU-written announcement they feed have no record handle to resolve from today. Inside + // this scope ONE read-only lifetime-id probe stays legal WHILE THE CURRENT RECORD IS + // BARRIERED; the scope is the debt's measurable, greppable form, and it retires with P7's + // server-side binding table. Every other apply-thread allocator access stays Fatal. + // + // THE DEPTH IS COUNTED ONLY ON THE APPLY THREAD (P5d round 3, package D). The counter's + // only reader is MGPipeRefuseAllocatorFromApplyThread, which returns before it looks unless + // ServerLoop::OnApplyThread() is true, so a depth kept on any OTHER thread could never + // change an answer - it was pure cost. The query is called ActiveOnApplyThread() and not + // Active() BECAUSE OF THAT (review round 3): a name that promised "is a scope open" would + // now be quietly answering "is a scope open ON THE APPLY THREAD", and the next reader to + // come along - a P4b/P7 probe deciding on the GL thread whether to emit a record - would + // read a truthful-looking false and take the wrong branch with nothing to warn it. The name + // carries the precondition so a second reader has to notice it. m_counted remembers what the constructor decided so + // the destructor undoes exactly what the constructor did; asking the predicate twice would + // leak a count for a scope that outlived the apply thread. On the GL thread (and in every + // monolith process) both ends are now one inlined predicate and a branch instead of an + // emutls call, which is where 32.7% of the monolith GL thread's __emutls_get_address - its + // top symbol at 7.7% - was going. + // + // P5e (id), ruling 12: RENAMED FROM MGPipeReverseAnnouncementScope AND KEYED ON THE + // BACKEND KIND. The rename is the finding: what is left inside it after P5e is not "the + // reverse announcement" - Espryt's half of that moved to the frontend-keyed scope with the + // rest of the registry debt - it is MAGMA'S FOUR APPLY-THREAD ALLOCATOR TOUCHES, which P7 + // retires (VulkanRenderer.cpp's two hidden-resource shutdowns and its named-blit endpoint + // resolve, plus MGPipeAnnounceBufferGpuWritten in ResourceTracker.h). Naming the scope + // after the debt rather than after one of its sites is what makes "has P7 landed yet" a + // grep; naming it after Magma is what stops a new Espryt site being wrapped in it. + // + // THE EXEMPTION IS REFUSED WHEN THE SERVER BACKEND IS NOT DirectVulkan, and that is the + // point of the key rather than a safety belt. Magma stays lockstep for the whole of P5e + // (§6.1: the DirectVulkan arm never publishes kCapRunAheadApply, so every record there is + // barriered and every probe inside this scope keeps P5C's semantics). Espryt is what P5e + // is retiring the lockstep for, so an Espryt probe must not be able to borrow Magma's + // exemption - even by wrapping itself in Magma's scope. + class MagmaP7AllocatorDebtScope { + public: + MagmaP7AllocatorDebtScope(); + ~MagmaP7AllocatorDebtScope(); + MagmaP7AllocatorDebtScope(const MagmaP7AllocatorDebtScope&) = delete; + MagmaP7AllocatorDebtScope& operator=(const MagmaP7AllocatorDebtScope&) = delete; + static Bool ActiveOnApplyThread(); + + private: + Bool m_counted; + }; + + // The SECOND named exemption family (CONTRACT-P5C §5.4): the frontend-keyed twin + // registry (audit row G6). The texture / sampler-view / FBO-legacy HandleOf probes are + // how the server answers "which twin is this frontend object" while the registry is + // keyed by frontend identity - server-PRIVATE state whose rekey onto handles is + // P5e's per-family packages', not P5c's. A probe inside this scope stays a read-only, + // BARRIER-HELD debt - and P5e (id) makes the "barrier-held" half literal rather than + // documentary: the exemption now holds only while MGPipeApplierCurrentRecordIsBarriered() + // (§4.4), i.e. only while the client is actually parked behind the record being applied. + // Wrapping a NEW site in it is the greppable act of naming that debt, and an unwrapped + // probe from the apply thread is still Fatal{RoleViolation, "MGPipeSlots"}. + // + // WHAT THE CLASS IS FOR NOW, since P5e deletes most of its sites: vi/sb/pg/tx2/fb delete + // their ~20 draw-path constructions with the probes they wrap, and what survives is the + // set of BARRIERED-ROW sites (CONTRACT-P5E §4.4: DirectGLES.cpp's CopyTex / GetTexImage / + // mipmap-shape / set_storage_block_binding / detach-walk sites, the two verify arms, and - + // until vi/sb carry the handle - BufferImpl::HandleOfBuffer). Those keep P5C's semantics + // because their records are barriered and their client IS parked. + // + // Apply-thread-only depth, and m_counted, for MagmaP7AllocatorDebtScope's reasons + // above. This is the scope that pays: it is constructed at ~20 sites in DirectGLES.cpp, + // several of them inside StateBackendObjectRegistry::HandleOf on the per-draw path, and + // its ctor/dtor alone were 18.75% + 13.95% of the monolith GL thread's emutls samples. + class MGPipeFrontendKeyedRegistryScope { + public: + MGPipeFrontendKeyedRegistryScope(); + ~MGPipeFrontendKeyedRegistryScope(); + MGPipeFrontendKeyedRegistryScope(const MGPipeFrontendKeyedRegistryScope&) = delete; + MGPipeFrontendKeyedRegistryScope& operator=(const MGPipeFrontendKeyedRegistryScope&) = delete; + static Bool ActiveOnApplyThread(); + + private: + Bool m_counted; + }; +#endif +} // namespace MobileGL::MG_Pipe diff --git a/MobileGL/MG_Impl/Pipe/TextureEmit.h b/MobileGL/MG_Impl/Pipe/TextureEmit.h new file mode 100644 index 000000000..46f9c9fb0 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/TextureEmit.h @@ -0,0 +1,1381 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/TextureEmit.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 + +// The CLIENT side of P4a's texture and renderbuffer family: resource_create from the object's +// constructor, resource_respecify from every storage-defining entry point, set_texture_params +// from the parameter mutators, and resource_subdata from the DRAIN LIST at the validate point. +// +// THE THREE OBJECT CALLS ARE NOT EMITTED FROM HERE'S CALLER, they are emitted from MG_State's +// own mutators - a constructor, a storage definition, a glTexParameter - exactly as P3a's +// buffer family is, because that is where the event happens. Only the sub-data drain runs at +// the validate point, which is the explicit exception ARCHITECTURE.md 5.1 makes for texture +// upload: walking every live texture per verb is the cost the drain list exists to avoid. +// +// THIS FILE IS CREATED BY THE CONTRACT COMMIT AND FILLED BY THE PACKAGE THAT OWNS IT - see +// FramebufferEmit.h for why, in full: PipeFill.cpp is the contract package's for the whole +// phase, so the emitter package edits this header and the value of +// kMGPipeWiredTextureSubsystem below, and never that file. +// +// HEADER-ONLY, for the ownership reason Tracker.h and ResourceTracker.h both state. +// +// --------------------------------------------------------------------------------------- +// HOW MG_State REACHES THIS FILE: IT DOES NOT, AND THAT IS THE POINT (c0b, ID-13). +// +// v1 of this package shipped a deviation - six free functions here, called from +// TextureObject.cpp and RenderbufferObject.cpp - because at the contract TAG +// MG_Pipe/PipeMutation.h declared only the six DEATH helpers and this package may not edit +// A's files. c0b landed the BIRTH half, so the deviation is retired rather than carried: +// MG_State now calls MGPipeMintTextureHandle / MGPipeEmitTextureResourceCreate / +// ...ResourceRespecify / MGPipeEmitTextureParams / MGPipeNoteTextureLevelDirty and the two +// renderbuffer twins, all DECLARED in MG_Pipe/PipeMutation.h and DEFINED in +// MG_Impl/Pipe/PipeFill.cpp, which forwards to the entry points below through +// ForwardWhenWired. No MG_State translation unit includes this +// header any more, which is the property check_include_closure.py's mutation-header probe +// exists to keep. +// +// WHAT THIS FILE OWES THAT SEAM, and a mismatch is a compile error in this package's own +// commit rather than a surprise at the merge (that is what the wired constant buys): +// +// EmitResourceCreate(ITextureObject&) EmitResourceRespecify(ITextureObject&) +// EmitTextureParams(ITextureObject&) NoteLevelDirty(ITextureObject&, Uint32, Uint32) +// EmitRenderbufferCreate(RenderbufferObject&) +// EmitRenderbufferRespecify(RenderbufferObject&) +// +// and the PUBLICATION LATCH is A's too: MGPipeNoteHandlePublished is called where a create +// actually goes out and MGPipeHandleIsPublished is what the death helpers read, so this file +// keeps no Published flag of its own. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace MobileGL::MG_Pipe { + + // WHICH SUBSYSTEM BIT THIS BUILD ACTUALLY EMITS FOR. PipeFill.cpp ORs the four per-family + // constants into kMGPipeWiredSubsystems, so the bit is added by the commit that gives the + // emitters their bodies, with no file touched twice - and a Coverage.def row can never + // silently drop a field on the floor before the call that carries it exists. + // + // IT WAS 0 IN v1, AND THE THING THAT BLOCKED IT IS NOW IN THE TREE. Unlike the other three + // P4a families this one gets no fresh apply entry points - the catalogue is closed and a + // texture rides P3a's OWN resource_create / resource_respecify / resource_subdata / + // resource_destroy rows - so on a base without the wire package's per-kind record vectors + // and its texture branch, two properties made a texture record actively harmful rather than + // merely ignored: MGPipeApplierState::Resources was ONE slot-indexed vector, so a texture + // create at slot 12 overwrote the BUFFER record at slot 12; and SubDataBoxFault validated + // every record as the buffer half of MGPSubData, so a texture sub-data record was + // Fatal{ProtocolCorruption} on `record.Level != 0` alone. Both are closed on this base + // (three per-kind vectors, ApplyTextureUpload, SubDataTextureFault, and C1's level-scoped + // PendingUploads clear), so the constant is its own bit and the family is live. + // + // THE FLIP IS THE WHOLE SWITCH AND NOTHING ELSE MOVES: PipeFill.cpp ORs this constant into + // kMGPipeWiredSubsystems, static_asserts it is 0-or-its-own-bit, gates every birth hook on + // FamilyIsLive(kMGPipeSubsystemTextureResources, this), and gates DrainTextureSubData on + // the same OR. Bit 9 (framebuffer) REQUIRES bit 10 (D-K2), because every MGPSurface::Res + // names a texture or renderbuffer handle the applier must hold a record for - so this + // package may never be integrated with only one of the two constants set. + inline constexpr Uint64 kMGPipeWiredTextureSubsystem = kMGPipeSubsystemTextureResources; + static_assert(kMGPipeWiredTextureSubsystem == 0 || + kMGPipeWiredTextureSubsystem == kMGPipeSubsystemTextureResources, + "a family's wired constant is 0 or its own bit and nothing else"); + + // BOTH HALVES MATTER, exactly as MGPipeResourceSubsystemEnabled()'s two do, and they are + // the SAME PAIR PipeFill.cpp's FamilyIsLive applies - the operator's per-subsystem A/B bit + // in MOBILEGL_PIPE_PUSH, and this build having wired the family at all. There is no third + // half: v1 carried a runtime `m_armed` latch so a unit case could drive the conversion on a + // base whose applier could not hold the record, and with the constant flipped that latch + // would only be able to LIE (PipeFill.cpp's gate does not consult it, so an unarmed emitter + // would still be driven by every frontend mutation). It is deleted; a case that wants the + // family off clears MG_Config::Features.PipePush, which is the switch the shipped build has. + inline Bool MGPipeTextureSubsystemEnabled(); + + // DO THE RECORDS THIS EMITTER BUILDS REACH THE APPLIER IN THIS BUILD? It is the wired + // constant asked as a COMPILE-TIME predicate, and with the constant set it is simply true - + // which is the point: every `if constexpr` below is taken, so the acceptance answers the + // emitter gates its own bookkeeping on are real answers rather than a default. + // + // IT IS KEPT RATHER THAN INLINED because it is what makes a build with the constant back at + // 0 - the A/B arm an operator gets by editing one line, and the arm a bisect lands on - + // compile with the applier calls discarded instead of half-wired. Where the difference + // matters is stated at each site: the dirty-flag clear may never run on a discarded call + // (D-D5 step 1), and the descriptor mirror may not advance past a call that never landed. + // + // set_texture_params is deliberately NOT behind it. It is addressed by RESOURCE and is the + // one call that closes D-E3's READ-attachment gap, and its BuiltinSampler comes from the + // sampler family rather than this one - so it rides bit 11's wiring, not bit 10's. + inline constexpr Bool MGPipeTextureRecordsReachTheApplier() { + return (kMGPipeWiredTextureSubsystem & kMGPipeSubsystemTextureResources) != 0; + } + + // --------------------------------------------------------------------------------- + // D-A3 / D-D1: the two discriminators a TEXTURE descriptor carries + // --------------------------------------------------------------------------------- + + // MGPResourceDesc::StorageKind == TextureStorageType, named rather than open-coded, and + // derived from the TARGET rather than from ITextureObject::GetStorageType(). + // + // THE TARGET IS THE ONLY LEGAL SOURCE AT THE ONE MOMENT THIS MATTERS: resource_create is + // emitted from TextureObjectBase's constructor (D-D1), where the derived object does not + // exist yet and GetStorageType() is a PURE virtual - calling it there is undefined + // behaviour, not merely a wrong answer. The mapping is exact and total: TextureObjectBuffer + // is the only class that reports Buffer and TextureTarget::TextureBuffer is the only target + // it is ever constructed with, which MGPipeTextureStorageKindAgreesWithObject below + // re-checks at the first respecify, where the object IS complete. + inline constexpr Uint8 MGPipeTextureStorageKindForTarget(MobileGL::TextureTarget target) { + return static_cast(target == MobileGL::TextureTarget::TextureBuffer + ? MobileGL::TextureStorageType::Buffer + : MobileGL::TextureStorageType::Mipmap); + } + + // MGPSubData::Target's PACKING AND THE TWO DEPTH-STENCIL NUMBERS ARE THE CONTRACT'S NOW + // (ID-12 DV-2/DV-3, c0c): MGPipePackSubDataTarget / MGPipeSubDataResourceTargetOf / + // MGPipeSubDataUploadTargetOf and kMGPipeDepthStencilModeDepth/Stencil live in + // MG_Pipe/MGPipeTypes.h under exactly these names, with Uint32 arguments so the header + // stays backend-neutral. This package's copies were the same spelling in the same + // namespace - a redefinition - and are deleted; the packing argument they carried is + // stated where the definitions now are. + // + // WHAT STAYS HERE is the GLenum -> byte translation, which is frontend knowledge: the + // frontend keeps GL_DEPTH_COMPONENT / GL_STENCIL_INDEX (0x1902 / 0x1901) and the payload + // byte cannot hold one. + inline Uint8 MGPipeDepthStencilModeByte(GLenum mode) { + return mode == GL_STENCIL_INDEX ? kMGPipeDepthStencilModeStencil : kMGPipeDepthStencilModeDepth; + } + + // --------------------------------------------------------------------------------- + // D-D1: the payload builders. Pure, so a unit case can assert field by field (G6), and + // one EXPECT per field is what G7's scripted control needs - it stops the conversion + // copying ONE member and expects the suite to go red NAMING it. + // --------------------------------------------------------------------------------- + + // The extent trio and the layer count, which are one question the frontend answers in + // three different axes depending on the target: a 1D array carries its layer count in the + // state-side HEIGHT, every other layered target carries it in z, and a cube map keeps six + // faces in six blobs and therefore reports none at all. + struct MGPipeTextureExtent { + Uint32 Width = 0; + Uint32 Height = 0; + Uint32 Depth = 0; + Uint16 ArrayLayers = 1; + }; + + inline MGPipeTextureExtent MGPipeTextureExtentOf(const MG_State::GLState::ITextureObject& texture) { + MGPipeTextureExtent extent{}; + const IntVec3 base = texture.GetBaseSize(); + extent.Width = base.x() > 0 ? static_cast(base.x()) : 0; + extent.Height = base.y() > 0 ? static_cast(base.y()) : 0; + extent.Depth = base.z() > 0 ? static_cast(base.z()) : 0; + switch (texture.GetTarget()) { + case MobileGL::TextureTarget::Texture1DArray: + extent.ArrayLayers = static_cast(std::max(base.y(), 1)); + break; + case MobileGL::TextureTarget::Texture2DArray: + case MobileGL::TextureTarget::TextureCubeMapArray: + case MobileGL::TextureTarget::Texture2DMultisampleArray: + extent.ArrayLayers = static_cast(std::max(base.z(), 1)); + break; + case MobileGL::TextureTarget::TextureCubeMap: + // Six blobs rather than six layers on this side of the boundary; the face rides in + // the sub-data record's upload-target byte and in MGPSurface::UploadTarget. + extent.ArrayLayers = 6; + break; + default: + extent.ArrayLayers = 1; + break; + } + return extent; + } + + // The descriptor for a TEXTURE. `storageDefined` is false for the create the constructor + // emits - storage is defined lazily by the first respecify and a backend tolerates a + // resource that has none - and true for every respecify. + // + // `viewOf`, `bufferForTexBuffer` and the buffer window are handed in rather than resolved + // here, because resolving them needs the slot allocator and this function must stay pure. + inline MGPResourceDesc MGPipeBuildTextureResourceDesc(const MG_State::GLState::ITextureObject& texture, + MGPipeHandle handle, Uint16 bindMask, + Bool storageDefined, MGPipeHandle viewOf, + MGPipeHandle bufferForTexBuffer, Uint64 bufOffset, + Uint64 bufSize) { + MGPResourceDesc desc{}; + desc.Resource = handle; + desc.Target = static_cast(MGPipeResourceTargetForTextureTarget(texture.GetTarget())); + desc.StorageKind = MGPipeTextureStorageKindForTarget(texture.GetTarget()); + desc.BindMask = bindMask; + // STICKY AND FOREVER: everImageBound, the client-side answer MGPipeTypes.h asks for. + // It is the PREVENTION half of the texture-remint stall class - a texture the server + // knows may be image-bound is allocated image-bindable up front, so the re-mint that + // would have to pull its texels back never happens. + desc.ImageBindableHint = (bindMask & kMGPipeBindShaderImage) != 0 ? 1 : 0; + if (storageDefined) { + const MGPipeTextureExtent extent = MGPipeTextureExtentOf(texture); + desc.InternalFormat = static_cast(texture.GetFormat()); + desc.Width = extent.Width; + desc.Height = extent.Height; + desc.Depth = extent.Depth; + desc.ArrayLayers = extent.ArrayLayers; + const auto* mipmap = MG_State::GLState::AsMipmapTexture(&texture); + desc.Levels = mipmap != nullptr ? static_cast(mipmap->GetMipmapLevelCount()) : 0; + desc.Samples = static_cast(std::max(texture.GetSamples(), 0)); + desc.FixedSampleLocations = texture.HasFixedSampleLocations() ? 1 : 0; + // A real descriptor fact the backend reads (glTexStorage* / a texture view), and + // it must NOT be read as "this respecify wants an acknowledgement": + // MGPipeResourceRespecifyNeedsAck is narrowed to name the buffer target, because + // texture allocation is lazy in monolith and stays lazy in split. + desc.Immutable = texture.IsImmutable() ? 1 : 0; + // "Storage exists and is not undefined". A mipmap texture answers with its level + // count, a buffer texture with whether a buffer is attached; both are what the + // backend's ensure path already tests before it uploads anything. + desc.HasDefinedContent = + (mipmap != nullptr ? mipmap->GetMipmapLevelCount() > 0 : !MGPipeHandleIsNull(bufferForTexBuffer)) + ? 1 + : 0; + } + desc.ViewOf = viewOf; + desc.BufferForTexBuffer = bufferForTexBuffer; + desc.BufOffset = bufOffset; + desc.BufSize = bufSize; + // Diagnostics only: a GL name is never an identity, never a memo key and never part of + // a content hash (ARCHITECTURE.md 4.2.1). + desc.GlNameForDiag = static_cast(texture.GetExternalIndex()); + return desc; + } + + // The descriptor for a RENDERBUFFER. A renderbuffer is an independent class on the wire + // (ARCHITECTURE.md 4.5.1) and shares nothing but the shape: no levels, no layers, no view, + // no buffer window, and a storage definition that is always the whole object. + inline MGPResourceDesc MGPipeBuildRenderbufferResourceDesc( + const MG_State::GLState::RenderbufferObject& renderbuffer, MGPipeHandle handle, Uint16 bindMask, + Bool storageDefined) { + MGPResourceDesc desc{}; + desc.Resource = handle; + desc.Target = static_cast(MGPipeResourceTarget::Renderbuffer); + // A renderbuffer is not a texture and has no TextureStorageType of its own; Mipmap is + // the non-buffer answer and is what keeps the one discriminator that matters - "is this + // a BUFFER store" - false for it. + desc.StorageKind = static_cast(MobileGL::TextureStorageType::Mipmap); + desc.BindMask = bindMask; + desc.ImageBindableHint = 0; + if (storageDefined) { + desc.InternalFormat = static_cast(renderbuffer.GetInternalFormat()); + desc.Width = renderbuffer.GetWidth() > 0 ? static_cast(renderbuffer.GetWidth()) : 0; + desc.Height = renderbuffer.GetHeight() > 0 ? static_cast(renderbuffer.GetHeight()) : 0; + desc.Depth = 1; + desc.ArrayLayers = 1; + desc.Levels = 1; + desc.Samples = static_cast(std::max(renderbuffer.GetSamples(), 0)); + desc.FixedSampleLocations = 1; + desc.HasDefinedContent = renderbuffer.IsAllocated() ? 1 : 0; + } + desc.GlNameForDiag = static_cast(renderbuffer.GetExternalIndex()); + return desc; + } + + // set_texture_params. Per texture OBJECT, independent of any view and of any binding - + // which is exactly what closes the gap D10 names: a texture that is only an FBO + // attachment, only an image-unit binding or only a glCopyImageSubData endpoint has a + // record the moment its parameters move, and the server applies it wherever it meets it. + // + // `builtinSampler` is handed in for MGPipeBuildTextureResourceDesc's reason (resolving it + // needs the allocator). It may NEVER be the null handle: every ITextureObject owns a + // SamplerObject, so a null there is a protocol corruption rather than "no sampler". + inline MGPTextureParams MGPipeBuildTextureParams(const MG_State::GLState::ITextureObject& texture, + MGPipeHandle handle, MGPipeHandle builtinSampler, + Bool forceResync) { + MGPTextureParams params{}; + params.Res = handle; + params.BuiltinSampler = builtinSampler; + const UintVec2& levelRange = texture.GetLevelRange(); + params.BaseLevel = static_cast(std::min(levelRange.x(), 0xFFFFu)); + params.MaxLevel = static_cast(std::min(levelRange.y(), 0xFFFFu)); + const Vec4& swizzle = texture.GetAllSwizzleParams(); + params.Swizzle[0] = static_cast(swizzle.r()); + params.Swizzle[1] = static_cast(swizzle.g()); + params.Swizzle[2] = static_cast(swizzle.b()); + params.Swizzle[3] = static_cast(swizzle.a()); + params.DepthStencilMode = MGPipeDepthStencilModeByte(texture.GetDepthStencilTextureMode()); + // BOTH RESYNC BYTES ARE THE SERVER'S TO SET AND THE CLIENT'S ONLY TO REQUEST, and the + // client has exactly one such request: the widened-channel swizzle override after an + // ImageBindableHint transition, which the frontend params version does not move for. + // The client never clears a server flag and the server never clears the client's. + params.ForceResync = forceResync ? 1 : 0; + params.SamplerResync = 0; + const auto& sampler = texture.GetSamplerObject(); + if (sampler) { + params.MinLod = sampler->GetMinLod(); + params.MaxLod = sampler->GetMaxLod(); + params.LodBias = sampler->GetLodBias(); + } + return params; + } + + // --------------------------------------------------------------------------------- + // D-D3: the sub-data shape. The union box AND the region list, so the SERVER picks the + // upload shape - the decision belongs on the side that pays the GPU cost, and Mali prices + // texture upload by JOB COUNT (~100 sprite rects against one union box = +6 ms/frame). + // --------------------------------------------------------------------------------- + + // The level's own pitches, in bytes, derived the way the frontend already sizes a level: + // the stored byte size divided by the texel count. A zero-texel level answers zero, which + // is what makes an unallocated level emit nothing rather than divide by zero. + struct MGPipeLevelPitch { + Uint32 BytesPerTexel = 0; + Uint32 RowStride = 0; + Uint32 SliceStride = 0; + }; + + inline MGPipeLevelPitch MGPipeLevelPitchOf(const IntVec3& levelSize, SizeT levelBytes) { + MGPipeLevelPitch pitch{}; + const Int64 width = std::max(levelSize.x(), 0); + const Int64 height = std::max(levelSize.y(), 0); + const Int64 depth = std::max(levelSize.z(), 1); + const Int64 texels = width * height * depth; + if (texels <= 0 || levelBytes == 0) return pitch; + pitch.BytesPerTexel = static_cast(static_cast(levelBytes) / texels); + pitch.RowStride = static_cast(pitch.BytesPerTexel * width); + pitch.SliceStride = static_cast(static_cast(pitch.RowStride) * height); + return pitch; + } + + inline MGPBox MGPipeBoxOfDirtyRegion(const MG_State::GLState::MipmapDirtyRegion& region) { + MGPBox box{}; + box.X = region.lo.x(); + box.Y = region.lo.y(); + box.Z = region.lo.z(); + box.W = static_cast(std::max(region.hi.x() - region.lo.x(), 0)); + box.H = static_cast(std::max(region.hi.y() - region.lo.y(), 0)); + box.D = static_cast(std::max(region.hi.z() - region.lo.z(), 0)); + return box; + } + + // ONE sub-region, with its strides CARRIED and never inferred (ARCHITECTURE.md 4.5.6): the + // old `uploadData == mipData` pointer comparison cannot survive a split, where the client + // neither ships the whole level nor keeps a server-side mirror of it. + // + // A WHOLE-LEVEL REGION LEAVES BOTH STRIDES 0 and that is not an omission: 0 means "tightly + // packed", the level shadow IS tightly packed, and the staging planner on the far side + // reads a 0 as `w * bpp`. A sub-rect's rows are not contiguous in the shadow, so it must + // carry the LEVEL's pitches - not its own width - or the server would repack the wrong + // bytes. + inline MGPSubRegion MGPipeBuildSubRegion(const MGPBox& box, const IntVec3& levelSize, + const MGPipeLevelPitch& pitch) { + MGPSubRegion region{}; + region.X = box.X; + region.Y = box.Y; + region.Z = box.Z; + region.W = box.W; + region.H = box.H; + region.D = box.D; + const Bool wholeLevel = box.X == 0 && box.Y == 0 && box.Z == 0 && + box.W >= static_cast(std::max(levelSize.x(), 0)) && + box.H >= static_cast(std::max(levelSize.y(), 0)) && + box.D >= static_cast(std::max(levelSize.z(), 1)); + region.SrcOffset = static_cast(box.Z) * pitch.SliceStride + + static_cast(box.Y) * pitch.RowStride + + static_cast(box.X) * pitch.BytesPerTexel; + region.SrcRowStride = wholeLevel ? 0u : pitch.RowStride; + region.SrcSliceStride = wholeLevel ? 0u : pitch.SliceStride; + return region; + } + + // --------------------------------------------------------------------------------- + // The emitter: handles, the inverse, the sticky mask, the drain list + // --------------------------------------------------------------------------------- + + class MGPipeTextureEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + using ITextureObject = MG_State::GLState::ITextureObject; + using TextureObjectMipmap = MG_State::GLState::TextureObjectMipmap; + using RenderbufferObject = MG_State::GLState::RenderbufferObject; + + // ---- handles ---- + // + // Minting is NOT gated on the subsystem bit, for MGPipeMintResourceHandle's reason: + // a handle is CLIENT state and set_framebuffer_state / set_sampler_views name a + // texture by handle whether or not the texture-resource family is switched on, so + // gating the mint would make the other subsystems emit null handles in exactly the + // A/B arm that exists to isolate them. Only the CALLS are gated. + MGPipeHandle AcquireTexture(Uint64 lifetimeId, ITextureObject* object) { + const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Texture, lifetimeId); + Entry& entry = EntryFor(m_textures, handle); + RetireIfRecycled(entry, handle); + entry.Texture = object; + entry.Gen = handle.Gen; + return handle; + } + MGPipeHandle FindTexture(const ITextureObject& texture) const { + return MGPipeSlots().FindByLifetimeId(MGPipeKind::Texture, texture.GetLifetimeId()); + } + MGPipeHandle AcquireRenderbuffer(Uint64 lifetimeId) { + const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::Renderbuffer, lifetimeId); + Entry& entry = EntryFor(m_renderbuffers, handle); + RetireIfRecycled(entry, handle); + entry.Gen = handle.Gen; + return handle; + } + MGPipeHandle FindRenderbuffer(const RenderbufferObject& renderbuffer) const { + return MGPipeSlots().FindByLifetimeId(MGPipeKind::Renderbuffer, renderbuffer.GetLifetimeId()); + } + + // The texture a handle names, or null. A RAW pointer is exact here for + // MGPipeResourceTracker::Resolve's reason: the entry exists only between the create the + // constructor emits and the destroy the destructor emits - and since the final review's + // C-2 that sentence is ESTABLISHED rather than assumed: the contract's death helper + // forwards to NoteTextureDied below before it frees the slot, so a dead handle finds a + // null pointer here. The Gen compare refuses a RECYCLED handle rather than resolving it + // to whatever now occupies the slot. + // + // A DEAD SLOT IS REFUSED, LOUDLY. The allocator's generation moves only at the NEXT + // hand-out, so between a death and a recycle a dead handle compares equal to the slot's + // generation - which is why the guard is IsLive and not GenOfSlot (the review's C-2: + // that compare guarded a recycled slot and never a dead one, and the drain then called + // a virtual on the freed object once per verb). Reaching this arm at all means a death + // path skipped the emitter, which is a seam defect and not traffic: it is counted and + // logged once, and the answer is null. + ITextureObject* ResolveTexture(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_textures.size()) return nullptr; + const Entry& entry = m_textures[slot]; + if (entry.Texture == nullptr || entry.Gen != handle.Gen) return nullptr; + if (!MGPipeSlots().IsLive(MGPipeKind::Texture, handle)) { + ++m_deadResolves; + MGLOG_E_ONCE("MGPipe: texture handle {slot=%u, gen=%u} is dead but the emitter still holds its " + "object - the death path did not retire the entry; refused rather than resolved", + handle.Slot, handle.Gen); + return nullptr; + } + return entry.Texture; + } + + // ---- the death half (P4a final review C-2) ---- + // + // CALLED BY THE CONTRACT'S DEATH HELPER, after the wire delete went out and BEFORE the + // slot is freed (ID-8's order: delete, notice, free - this sits between the first two). + // v2 had no such door: the helper freed the slot, the emitter kept the freed + // ITextureObject* and the level on the drain list, and `glTexImage2D; glDeleteTextures; + // ` walked freed memory at the next validate point - a SIGABRT ("pure virtual + // method called") at the shipping mask. Everything the entry owns goes here: the drain + // entries (nothing is owed for a dead texture - its record is gone with the wire delete), + // the built-in sampler's cache reference (ID-17: one per entry, released at the death + // and no longer at the recycle), the latches and the sticky mask. RetireIfRecycled stays + // as the belt for a slot whose death this emitter was never told about. + // + // Keyed on the GENERATION so a late notice for a slot that has already been handed out + // again cannot retire the successor's entry. + void NoteTextureDied(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_textures.size()) return; + Entry& entry = m_textures[slot]; + if (entry.Gen != handle.Gen) return; + if (!entry.DrainKeys.empty()) { + // A death inside the drain cannot happen (no SharedPtr drops there), but if one + // ever did the loop below is iterating m_drain: the null pointer the reset + // leaves is what makes EmitOneLevel answer "nothing owed" and drop the entry. + if (!m_draining) { + SizeT kept = 0; + for (SizeT i = 0; i < m_drain.size(); ++i) { + if (m_drain[i].Handle == handle) continue; + m_drain[kept++] = m_drain[i]; + } + m_drain.resize(kept); + } + entry.DrainKeys.clear(); + } + MGPipeSamplerCsoCacheInstance().Release(entry.BuiltinSampler); + entry = Entry{}; + } + void NoteRenderbufferDied(MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_renderbuffers.size()) return; + Entry& entry = m_renderbuffers[slot]; + if (entry.Gen != handle.Gen) return; + entry = Entry{}; + } + + // ---- the sticky bind mask (D-A4) ---- + // + // ORed, never cleared, and emitted on BOTH resource_create and every + // resource_respecify, exactly as P3a's buffer mask is. The four bits nothing set before + // P4a get their producers here and in the framebuffer emitter: RENDER_TARGET and + // DEPTH_STENCIL from an attachment point (FramebufferEmit.h), SAMPLER from a resolved + // sampler view (SamplerEmit.h) and SHADER_IMAGE from glBindImageTexture's state setter + // and the resolved image unit (TextureState.h, ImageEmit.h) - the last two through the + // contract's MGPipeNoteTextureBoundAs door, since neither may include this header + // (final review M-A: before the fix round nothing produced them and the hint was dead). + // A MASK CHANGE AFTER THE ALLOCATION IS A METADATA RESPECIFY (ID-18 M4), and without it + // the sticky half of D-A4 is a no-op for exactly the textures it was written for. The + // mask rides resource_create and every resource_respecify - and an IMMUTABLE texture has + // no further respecify, that being what immutable means - so for the canonical order + // `glTexStorage2D(...); glBindImageTexture(...)` the applier's record kept + // ImageBindableHint = 0 for ever and the PREVENTION half of the texture-remint stall + // class never fired. So a mask that actually MOVES re-emits the stored descriptor with + // the new mask: every storage-defining field is byte-identical to what the applier + // holds, which is exactly the shape wire applies as a METADATA UPDATE - the descriptor + // is replaced, no reallocation is acked, and NO pending upload is dropped, so a mask + // change arriving between a glTexSubImage2D and the sync that consumes it cannot eat + // the texels. + void NoteTextureBoundAs(MGPipeHandle handle, Uint16 bit) { + if (MGPipeHandleIsNull(handle)) return; + Entry& entry = EntryFor(m_textures, handle); + // The entry is stamped with the generation it is written under, and a predecessor's + // entry on a recycled slot is retired first (the same door AcquireTexture takes): a + // texture born while the family bit was clear has no create to have done it. + RetireIfRecycled(entry, handle); + entry.Gen = handle.Gen; + const Uint16 before = entry.BindMask; + const Uint16 now = static_cast(before | bit); + if (now == before) return; + entry.BindMask = now; + // AN ImageBindableHint TRANSITION IS THE ONE THING THE CLIENT ASKS A RESYNC FOR + // (D-E2): the widened-channel carrier needs a swizzle override that the frontend's + // own params version does not move for, so the transition arms ForceResync on the + // next set_texture_params rather than being silently folded into the descriptor. + // SamplerResync stays the SERVER's byte and is never set from here. + if ((before & kMGPipeBindShaderImage) == 0 && (bit & kMGPipeBindShaderImage) != 0) { + entry.ForceParamsResync = true; + } + RepublishMask(MGPipeKind::Texture, handle, entry); + } + void NoteRenderbufferBoundAs(MGPipeHandle handle, Uint16 bit) { + if (MGPipeHandleIsNull(handle)) return; + Entry& entry = EntryFor(m_renderbuffers, handle); + RetireIfRecycled(entry, handle); + entry.Gen = handle.Gen; + const Uint16 before = entry.BindMask; + const Uint16 now = static_cast(before | bit); + if (now == before) return; + entry.BindMask = now; + RepublishMask(MGPipeKind::Renderbuffer, handle, entry); + } + Uint16 TextureBindMask(MGPipeHandle handle) const { return MaskOf(m_textures, handle); } + Uint16 RenderbufferBindMask(MGPipeHandle handle) const { return MaskOf(m_renderbuffers, handle); } + + // ---- the three object calls (the entry points PipeFill.cpp forwards to) ---- + + // resource_create, from TextureObjectBase's CONSTRUCTOR - so the DERIVED object does + // not exist yet and only members TextureObjectBase itself implements may be read. + // GetTarget(), GetExternalIndex() and GetLifetimeId() are all overridden ON THE BASE, + // so they dispatch to the base's own bodies here and read members the mem-init list has + // already written; GetStorageType() and GetUploadTargets() are NOT, so calling either + // would be undefined behaviour and the storage kind is derived from the target instead + // (exact, and re-checked at the first respecify where the object IS complete). + void EmitResourceCreate(ITextureObject& texture) { + const MGPipeHandle handle = AcquireTexture(texture.GetLifetimeId(), &texture); + Entry& entry = EntryFor(m_textures, handle); + MGPResourceDesc desc{}; + desc.Resource = handle; + desc.Target = static_cast(MGPipeResourceTargetForTextureTarget(texture.GetTarget())); + desc.StorageKind = MGPipeTextureStorageKindForTarget(texture.GetTarget()); + desc.BindMask = entry.BindMask; + desc.ImageBindableHint = (entry.BindMask & kMGPipeBindShaderImage) != 0 ? 1 : 0; + desc.GlNameForDiag = static_cast(texture.GetExternalIndex()); + NoteDesc(desc, /*isCreate=*/true); + PublishCreate(MGPipeKind::Texture, handle, entry, desc); + } + + // resource_respecify, from every storage-defining entry point, WITH THE SCOPE OF THE + // STORAGE IT REPLACES (P4a final review C-1; the scopes are PipeMutation.h's). + // + // THE LEVEL IS PASSED, AND IT IS WIRE'S KEY. The applier keeps a pending-upload set per + // (uploadTarget, level) - the client's dirty flags, inverted - and a respecify drops the + // entries against the storage it REPLACES: with a null MGPRespecifiedLevel every entry, + // with a level exactly that one. v2 passed null at every call, so a level the applier + // had ACCEPTED at one verb (the client flag already clear, D-D5 step 1) and that the + // next verb's glTexImage2D(level 1) or glGenerateMipmap grow defined AROUND was dropped + // with nobody owing its texels: `L0; draw(other); L1; draw(T)` read a black level 0. + // The key is built from the SAME packed MGPSubData::Target the drain puts in that + // level's record (wire-v3 §5 item 5), so what this drops is what that emission made. + // + // THREE SCOPES, one call each for the first two and one call PER REMOVED LEVEL for the + // chain cut: the applier's key is one (uploadTarget, level), so "every level from N" + // is spelled as N.., each after the first landing on an unchanged descriptor - which + // the applier classifies as a metadata update that drops nothing but the level it + // names. That is the refinement wire's W11 clause takes this round. + // + // DEDUPED ON THE DESCRIPTOR ITSELF for the whole-resource form only: the entry points + // that reach it move the SHAPE and several of them do not move the descriptor at all + // (glTexParameter TEXTURE_BASE_LEVEL bumps the shape version and changes no field this + // record carries), and a byte compare of an 88-byte POD is cheaper than the emission + // it avoids. A PER-LEVEL form is never deduped: the level it redefines is not in the + // descriptor (a non-base level's extent moves no field), so an unchanged descriptor + // cannot say whether the applier still holds a box against the OLD level - and a box + // kept across a shrink is uploaded past the end of the new one. One applier call per + // level definition is the cost, and the sub-data that follows moves the serial anyway. + void EmitResourceRespecify(ITextureObject& texture, MGPipeTextureRespecifyScope scope, + Uint32 uploadTarget, Uint32 level) { + const MGPipeHandle handle = AcquireTexture(texture.GetLifetimeId(), &texture); + // THE VIEW'S OWNER IS ACQUIRED FIRST, and no Entry& is held across it (m3): the + // owner's slot can be higher than this table's size, so AcquireTexture would + // resize() the vector out from under a reference taken before it. Every Entry& + // below is taken after the last call that can grow the table. + MGPipeHandle viewOf = kMGPipeNullHandle; + if (const auto& owner = texture.GetViewStorageOwner()) { + // 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 means. + viewOf = AcquireTexture(owner->GetLifetimeId(), owner.get()); + } + MGPipeHandle bufferHandle = kMGPipeNullHandle; + Uint64 bufOffset = 0; + Uint64 bufSize = 0; + if (texture.GetStorageType() == MobileGL::TextureStorageType::Buffer) { + auto& bufferTexture = static_cast(texture); + const auto& backing = bufferTexture.GetBufferBindingSlot().GetBoundObject(); + if (backing) { + // Bit 10 REQUIRES bit 7 for exactly this: only the resource subsystem puts + // a twin behind a Buffer handle, and a buffer texture's descriptor names + // one. The handle itself is minted whatever the bits say, because a mint is + // client state. + bufferHandle = MGPipeSlots().Acquire(MGPipeKind::Buffer, backing->GetLifetimeId()); + bufOffset = static_cast(bufferTexture.GetBufferRangeOffset()); + // RESOLVED LIVE, which is what ARCHITECTURE.md 4.5.1 asks for: glTexBuffer + // attaches the whole buffer and a stored size would freeze the texture at + // whatever size the buffer happened to have. + bufSize = bufferTexture.GetBufferRangeOffset() == 0 && + bufferTexture.GetBufferRangeSizeInBytes() == backing->GetSize() + ? kMGPipeWholeBuffer + : static_cast(bufferTexture.GetBufferRangeSizeInBytes()); + } + } + Entry& entry = EntryFor(m_textures, handle); + const MGPResourceDesc desc = MGPipeBuildTextureResourceDesc( + texture, handle, entry.BindMask, /*storageDefined=*/true, viewOf, bufferHandle, bufOffset, + bufSize); + const Bool unchanged = entry.HasLastDesc && std::memcmp(&entry.LastDesc, &desc, sizeof(desc)) == 0; + + // THE KEYS THIS CALL DROPS. `keyCount == 0` is the whole resource (a null level + // pointer); otherwise `keyCount` keys from `firstLevel` up, all on `uploadTarget`. + Uint32 firstLevel = 0; + Uint32 keyCount = 0; + switch (scope) { + case MGPipeTextureRespecifyScope::OneLevel: + firstLevel = level; + keyCount = 1; + break; + case MGPipeTextureRespecifyScope::LevelsFrom: { + // A cut at 0 leaves nothing: the whole resource. Otherwise the removed levels + // are [level, the level count the applier last accepted): LastDesc mirrors + // acceptance, and a sub-data for a level the accepted descriptor does not + // describe is refused by the applier, so no key above that count can exist. A + // cut that removes nothing the applier could hold is deduped like the + // whole-resource form; if the descriptor moved anyway the first key carries it. + if (level == 0) break; + const Uint32 previous = entry.HasLastDesc ? static_cast(entry.LastDesc.Levels) : 0u; + if (previous <= level && unchanged) return; + firstLevel = level; + keyCount = previous > level ? previous - level : 1u; + break; + } + case MGPipeTextureRespecifyScope::WholeResource: + default: + if (unchanged) return; + break; + } + + // SELF-HEALING IN BOTH DIRECTIONS, the P3a m12 shape: a texture born while the + // subsystem bit was clear has no applier record, and every later respecify would be + // REFUSED. A create rather than a respecify, because that is what the record's + // absence means and because the applier starts a record over on a create. + if (!MGPipeHandleIsPublished(MGPipeKind::Texture, handle)) { + const MGPResourceDesc createDesc = MGPipeBuildTextureResourceDesc( + texture, handle, entry.BindMask, /*storageDefined=*/false, viewOf, bufferHandle, + bufOffset, bufSize); + NoteDesc(createDesc, /*isCreate=*/true); + PublishCreate(MGPipeKind::Texture, handle, entry, createDesc); + } + NoteDesc(desc, /*isCreate=*/false); + // NO initial bytes: a texture's texels travel as resource_subdata out of the drain + // list, never inside its storage definition. This is what keeps glTexImage2D's + // "define the level and upload it" one allocation and one upload rather than two. + // + // AND THE MIRROR ONLY ADVANCES ON ACCEPTANCE (ID-18 M3): the dedupe above is a claim + // about what the APPLIER holds, so a refused respecify must leave LastDesc naming + // the descriptor that actually landed, or the next identical call is suppressed + // against a record that was never stored. + // + // THE PACKED TARGET IS THE DRAIN's (wire-v3 §5 item 5): the contract's packer takes + // two Uint32s, low byte the resource target, high byte the upload target (a cube + // face), and the applier matches the key against the sub-data records verbatim. + const Uint16 packedTarget = MGPipePackSubDataTarget( + static_cast(MGPipeResourceTargetForTextureTarget(texture.GetTarget())), uploadTarget); + Bool accepted = false; + if (keyCount == 0) { + accepted = RespecifyOnce(texture, handle, entry, desc, nullptr, viewOf, bufferHandle, bufOffset, + bufSize); + } else { + for (Uint32 i = 0; i < keyCount; ++i) { + MGPRespecifiedLevel key{}; + key.UploadTarget = packedTarget; + key.Level = static_cast(firstLevel + i); + accepted = RespecifyOnce(texture, handle, entry, desc, &key, viewOf, bufferHandle, bufOffset, + bufSize); + if (!accepted) break; + } + } + NoteRespecified(entry, desc, accepted); + } + + void EmitTextureParams(ITextureObject& texture) { + const MGPipeHandle handle = AcquireTexture(texture.GetLifetimeId(), &texture); + Entry& entry = EntryFor(m_textures, handle); + const auto& sampler = texture.GetSamplerObject(); + if (!sampler) { + // Structurally impossible - TextureObjectBase's constructor makes one - but a + // null BuiltinSampler is Fatal{ProtocolCorruption} on the far side, so the + // record is not sent rather than sent wrong. + MGLOG_E_ONCE("MGPipe: texture %u has no sampler object; set_texture_params is dropped " + "rather than emitted with a null BuiltinSampler", + texture.GetExternalIndex()); + return; + } + // THE VERSION-FIRST SKIP, AND IT READS BOTH COUNTERS (clientsp-v2 rule 4, and it is + // the M2 defect stated as a rule): glTexParameter* moves GetTextureParamsVersion() + // AND lands on the built-in SamplerObject, but the three fields this record takes + // off that object - MinLod, MaxLod, LodBias - are ALSO reachable through paths that + // move only SamplerObject::GetVersion(). Latching on the texture's counter alone is + // what let glTexParameterf(GL_TEXTURE_MIN_LOD) go stale. ForceParamsResync is the + // third input because an ImageBindableHint transition moves neither counter. + const Uint16 paramsVersion = texture.GetTextureParamsVersion(); + const Uint16 samplerVersion = sampler->GetVersion(); + if (entry.HasParamsLatch && entry.ParamsVersion == paramsVersion && + entry.SamplerVersion == samplerVersion && !entry.ForceParamsResync) { + return; + } + // THE LATCH IS TAKEN BELOW, ON ACCEPTANCE (final review m-1, audit F-7) - like the + // sub-data and respecify paths, and unlike v2, which advanced it here and left a + // refused record (no applier record for the handle, the SD-1/SD-3 shape) unsent + // until the next glTexParameter* moved a version. + + // ID-14 / ID-17: THE BUILT-IN SAMPLER COMES FROM C's CONTENT-ADDRESSED CACHE and is + // never minted here. v1 took MGPipeSlots().Acquire(SamplerCso, the SamplerObject's + // lifetime id), which is a slot no create_sampler_state ever names - so on the + // integrated tree every texture's params record would have carried a handle the + // applier holds nothing for. The cache mints and emits create_sampler_state on a + // miss, so the texture's built-in sampler and a glBindSampler'd object with the + // same value share ONE CSO and one server-side twin. + // + // EVERY Acquire TAKES A REFERENCE AND THIS ENTRY OWES EXACTLY ONE. The reference is + // what stops the LRU pulling a handle out from under a standing MGPTextureParams + // record: the applier deliberately does not resolve BuiltinSampler, and an eviction + // is not a parameter change, so nothing would refuse and nothing would re-emit. The + // previous handle is released when the content moves it, and the last one at the + // texture's death (NoteTextureDied, reached from the contract's death helper) - or + // at the recycle, as the belt, for a death this emitter was not told about. + MGPipeSamplerCsoCache& cache = MGPipeSamplerCsoCacheInstance(); + Uint64 samplerBytes = 0; + const MGPipeHandle builtinSampler = + cache.Acquire(sampler->GetAllSamplerParameters(), samplerBytes); + m_samplerCsoPayloadBytes += samplerBytes; + if (entry.BuiltinSampler == builtinSampler) { + // The value did not move, so the cache handed back the handle this entry + // already pins AND a second reference for it. Give that one straight back. + cache.Release(builtinSampler); + } else { + cache.Release(entry.BuiltinSampler); // a no-op for the null handle + entry.BuiltinSampler = builtinSampler; + } + + const MGPTextureParams params = + MGPipeBuildTextureParams(texture, handle, entry.BuiltinSampler, entry.ForceParamsResync); + m_lastParams = params; + ++m_paramSets; + // Not behind MGPipeTextureRecordsReachTheApplier() (see its comment): the call is + // dispatched whenever this emitter runs, so the answer is always a real one. + Bool accepted = MGPipeRouteSetTextureParams(params); + if (!accepted) { + // THE SELF-HEAL, the respecify path's shape, and the parameters are the one + // publication that may be a texture's FIRST: the context's default textures are + // constructed before the backend registers its consumer, so no create ever went + // out for them, and the application's first glTexParameter* on texture 0 found + // no record (the retrace census's residual once this refusal went loud). A + // create with no storage gives the record its identity, the storage follows if + // the texture has any (a respecify against the create's descriptor is never + // deduped away), and the parameters land on the record that now exists. The + // same repair covers the served context's teardown scope, where the records are + // dropped while the objects live on. One retry, never a loop. + const MGPResourceDesc healDesc = MGPipeBuildTextureResourceDesc( + texture, handle, entry.BindMask, /*storageDefined=*/false, kMGPipeNullHandle, + kMGPipeNullHandle, 0, 0); + NoteDesc(healDesc, /*isCreate=*/true); + PublishCreate(MGPipeKind::Texture, handle, entry, healDesc); + const auto* mipmap = MG_State::GLState::AsMipmapTexture(&texture); + const Bool hasStorage = mipmap != nullptr + ? mipmap->GetMipmapLevelCount() > 0 + : texture.GetStorageType() == MobileGL::TextureStorageType::Buffer; + if (hasStorage) { + // Can grow the table (a view's owner is acquired inside): no Entry& is held + // across it - `entry` is re-fetched below. + EmitResourceRespecify(texture, MGPipeTextureRespecifyScope::WholeResource, 0, 0); + } + accepted = MGPipeRouteSetTextureParams(params); + } + Entry& latched = EntryFor(m_textures, handle); + if (!accepted) { + // Refused on its merits (a null built-in sampler, no consumer). Nothing latched: + // the same versions re-send at the next call. Loud for the reason the sub-data + // refusal is loud. + ++m_refusedParamSets; + MGLOG_E_ONCE("MGPipe: set_texture_params for texture %u {slot=%u, gen=%u} was refused; the " + "latch is not taken and the parameters are re-sent at the next call", + texture.GetExternalIndex(), handle.Slot, handle.Gen); + return; + } + latched.HasParamsLatch = true; + latched.ParamsVersion = paramsVersion; + latched.SamplerVersion = samplerVersion; + latched.ForceParamsResync = false; + } + + void EmitRenderbufferCreate(RenderbufferObject& renderbuffer) { + const MGPipeHandle handle = AcquireRenderbuffer(renderbuffer.GetLifetimeId()); + Entry& entry = EntryFor(m_renderbuffers, handle); + const MGPResourceDesc desc = MGPipeBuildRenderbufferResourceDesc(renderbuffer, handle, + entry.BindMask, + /*storageDefined=*/false); + NoteDesc(desc, /*isCreate=*/true); + PublishCreate(MGPipeKind::Renderbuffer, handle, entry, desc); + } + + // D-D2: THE RENDERBUFFER PUBLICATION HOLE, CLOSED BY EMISSION. + // + // RenderbufferObject::{SetInternalFormat, AllocateStorage, SetSamples} bump no version + // and raise no notice, and the framebuffer dirty bit's shutter does not move when an + // ALREADY-ATTACHED renderbuffer is re-storaged - so `glBindRenderbuffer; + // glRenderbufferStorage(newSize)` on an attached renderbuffer was invisible. It is + // closed HERE, from the storage entry point, and deliberately not by adding a version + // counter to RenderbufferObject (a new member resizes the pull build's object and + // breaks G1) nor by widening the shutter (which would fire the framebuffer emission on + // an unrelated renderbuffer write). + void EmitRenderbufferRespecify(RenderbufferObject& renderbuffer) { + const MGPipeHandle handle = AcquireRenderbuffer(renderbuffer.GetLifetimeId()); + Entry& entry = EntryFor(m_renderbuffers, handle); + const MGPResourceDesc desc = MGPipeBuildRenderbufferResourceDesc(renderbuffer, handle, + entry.BindMask, + /*storageDefined=*/true); + if (entry.HasLastDesc && std::memcmp(&entry.LastDesc, &desc, sizeof(desc)) == 0) return; + if (!MGPipeHandleIsPublished(MGPipeKind::Renderbuffer, handle)) { + const MGPResourceDesc createDesc = MGPipeBuildRenderbufferResourceDesc( + renderbuffer, handle, entry.BindMask, /*storageDefined=*/false); + NoteDesc(createDesc, /*isCreate=*/true); + PublishCreate(MGPipeKind::Renderbuffer, handle, entry, createDesc); + } + NoteDesc(desc, /*isCreate=*/false); + // A renderbuffer's storage is always the whole object: no levels, so no key. + Bool accepted = ApplyRespecify(desc, nullptr); + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + if (!accepted) { + // See the texture twin: the applier's refusal is the only thing that can + // say "I hold no record for this handle" once the latch has been set. + const MGPResourceDesc healDesc = MGPipeBuildRenderbufferResourceDesc( + renderbuffer, handle, entry.BindMask, /*storageDefined=*/false); + NoteDesc(healDesc, /*isCreate=*/true); + PublishCreate(MGPipeKind::Renderbuffer, handle, entry, healDesc); + accepted = ApplyRespecify(desc, nullptr); + } + } + NoteRespecified(entry, desc, accepted); + } + + // ---- the drain list (D-D4) ---- + // + // Appended ONCE, on the first dirty mark of a level, and cleared at emission. Keyed on + // the STORAGE OWNER from day one and for free: TextureObjectView forwards + // MarkStorageDirty / MarkStorageDirtyRegion to the OWNER's methods after remapping the + // level and the region, so an upload through a view and an upload through the owner + // reach this function with the same object and the same owner-side coordinates. + // + // The per-slot key list is a short linear scan rather than a hash: a level count is + // ~15, the cap on the rect list behind it is 96, and this runs on the glTexSubImage + // path which has just memcpy'd texels. + // THE PARAMETER TYPES ARE THE CONTRACT'S (PipeMutation.h): Uint32 rather than + // MobileGL::TextureUploadTarget and Uint, because that declaration is the one door + // MG_State has into the client and it may not name a frontend enumeration. + // + // THERE IS NO CLEAN ARM, and that is a DECLARED DEVIATION rather than a dropped half. + // v1 carried a second entry point for MarkStorageDirty(..., false); the contract's hook + // has no `dirty` parameter, and asking A to widen it would put a second signature in + // MG_Pipe/PipeMutation.h for something the drain already collects. A level that goes + // clean stays on the list until the NEXT drain walks it, where + // `!mipmap->IsStorageDirty(...)` is the first test EmitOneLevel makes and returns + // "nothing owed", so the entry is dropped from both lists there. The cost is one + // IsStorageDirty call per cleaned level per drain, the list is bounded by the (texture, + // level) pairs dirtied since the last validate point, and a re-dirty before that drain + // is already covered by the entry still standing. What it must NOT be confused with is + // dropping the level's TEXELS: nothing here clears a dirty flag. + void NoteLevelDirty(ITextureObject& texture, Uint32 uploadTarget, Uint32 level) { + if (m_draining) return; + const MGPipeHandle handle = AcquireTexture(texture.GetLifetimeId(), &texture); + Entry& entry = EntryFor(m_textures, handle); + const Uint32 key = PackLevelKey(static_cast(uploadTarget), + static_cast(level)); + for (const Uint32 present : entry.DrainKeys) { + if (present == key) return; + } + entry.DrainKeys.push_back(key); + m_drain.push_back(DrainEntry{handle, key}); + } + + // The DRAIN, at the validate point: one resource_subdata per dirty (storage owner, + // upload target, level). + // + // WHO CLEARS THE FLAG, and why a bail cannot lose texels (D-D5): the client clears its + // own m_isDirty / region / rects for a level ONLY when the record was actually + // dispatched, and the applier accumulates the emitted shape into a per-record + // pending-upload set that is server-side and survives every one of Espryt's bail arms. + // A level whose record could not be built - no storage, no shadow, an empty box - + // stays dirty and stays on the list, which is the safe direction. + // + // Returns the bytes that went on the wire, for the per-draw payload histogram. + Uint64 DrainTextureSubData(GLContext& ctx) { + (void)ctx; + // THE EARLY-OUT, before anything is hashed or resolved: with nothing dirty this is + // one integer test per verb, which is the whole reason the drain list exists + // rather than a walk over every live texture. + if (m_drain.empty()) return 0; + m_draining = true; + Uint64 bytes = 0; + Vector retry; + for (const DrainEntry& pending : m_drain) { + if (EmitOneLevel(pending, bytes)) continue; + retry.push_back(pending); + } + // Every slot's key list is rebuilt from what actually stayed behind, so a level + // that was emitted is off both lists and a level that bailed is on both. + for (const DrainEntry& pending : m_drain) { + const SizeT slot = pending.Handle.Slot; + if (slot < m_textures.size()) m_textures[slot].DrainKeys.clear(); + } + for (const DrainEntry& pending : retry) { + const SizeT slot = pending.Handle.Slot; + if (slot < m_textures.size()) m_textures[slot].DrainKeys.push_back(pending.Key); + } + m_drain = Move(retry); + m_draining = false; + return bytes; + } + + // ---- what a unit case reads. None of it costs a copy on the hot path: the two + // descriptors are written by create and respecify, which run once per storage + // definition rather than per upload, and the sub-data record is the emitter's own + // staging buffer handed straight to the applier. ---- + const MGPResourceDesc& LastDesc() const { return m_lastDesc; } + const MGPTextureParams& LastParams() const { return m_lastParams; } + const MGPSubData& LastSubData() const { return m_lastSubData; } + const Vector& LastRegions() const { return m_regions; } + Uint64 CreateCount() const { return m_creates; } + Uint64 RespecifyCount() const { return m_respecifies; } + Uint64 ParamCount() const { return m_paramSets; } + Uint64 SubDataCount() const { return m_subDatas; } + // Records the applier REFUSED. The dirty flag survives one of these, which is the whole + // of D-D5 step 1 - so a case that wants to prove the flag survived asserts on this. + Uint64 RefusedSubDataCount() const { return m_refusedSubDatas; } + // set_texture_params records the applier refused; the latch survives one of these (m-1). + Uint64 RefusedParamCount() const { return m_refusedParamSets; } + // What create_sampler_state put on the wire on this emitter's behalf, so the csob-blob + // accounting does not under-report 100 bytes per built-in sampler mint. set_texture_params + // itself returns no byte count - it is not emitted from the validate point's payload + // histogram - so this is where the cache's answer lands. + Uint64 SamplerCsoPayloadBytes() const { return m_samplerCsoPayloadBytes; } + // Dead handles that still held an object when resolved: a death path that skipped the + // emitter. 0 on a healthy tree; a case that drives every death path asserts it. + Uint64 DeadResolveCount() const { return m_deadResolves; } + MGPipeHandle BuiltinSamplerOf(MGPipeHandle handle) const { + const SizeT slot = handle.Slot; + if (MGPipeHandleIsNull(handle) || slot >= m_textures.size()) return kMGPipeNullHandle; + const Entry& entry = m_textures[slot]; + return entry.Gen == handle.Gen ? entry.BuiltinSampler : kMGPipeNullHandle; + } + SizeT DrainListSize() const { return m_drain.size(); } + + // 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. + // + // THE DRAIN LIST IS NOT A LATCH AND IS NOT CLEARED. It is a list of texels the client + // still owes the server, and the server's pending-upload set is per RECORD, which + // MGPipeApplierReset deliberately keeps. Clearing it here would drop exactly the + // uploads a context switch has not flushed yet. + void Reset() {} + + void ResetCounters() { + m_creates = m_respecifies = m_paramSets = m_subDatas = 0; + m_refusedSubDatas = 0; + m_refusedParamSets = 0; + m_samplerCsoPayloadBytes = 0; + m_deadResolves = 0; + } + + // A unit fixture's per-case reset; the library never calls it. See + // MGPipeResourceTracker::ResetForTest for the rule this restates: a texture handle and + // the applier record it names are SHARE-GROUP OBJECT STATE, so nothing here is + // per-context and no re-publication path exists or may exist. + void ResetForTest() { + // EVERY REFERENCE THIS EMITTER OWES IS GIVEN BACK FIRST. A case that dropped the + // table without releasing would pin cache entries for the rest of the process and + // the next case's LRU would mint over capacity for reasons it cannot see. + for (Entry& entry : m_textures) { + MGPipeSamplerCsoCacheInstance().Release(entry.BuiltinSampler); + entry.BuiltinSampler = kMGPipeNullHandle; + } + m_textures.clear(); + m_renderbuffers.clear(); + m_drain.clear(); + m_draining = false; + m_regions.clear(); + m_lastDesc = MGPResourceDesc{}; + m_lastParams = MGPTextureParams{}; + m_lastSubData = MGPSubData{}; + ResetCounters(); + } + + private: + struct Entry { + ITextureObject* Texture = nullptr; + Uint32 Gen = 0; + Uint16 BindMask = 0; + Bool ForceParamsResync = false; + Bool HasLastDesc = false; + // ID-14/ID-17: the CSO C's content-addressed cache handed this texture's BUILT-IN + // sampler, and the ONE reference this emitter owes a Release for. Null until the + // first set_texture_params. There is no Published flag beside it: c0b's + // {kind, slot, gen} latch is the one answer both halves read. + MGPipeHandle BuiltinSampler{}; + // The version-first skip for set_texture_params, and it reads BOTH counters + // (clientsp-v2 rule 4): glTexParameter* moves GetTextureParamsVersion(), a write + // that lands on the built-in SamplerObject moves only SamplerObject::GetVersion(). + Bool HasParamsLatch = false; + Uint16 ParamsVersion = 0; + Uint16 SamplerVersion = 0; + MGPResourceDesc LastDesc{}; + Vector DrainKeys; + }; + + struct DrainEntry { + MGPipeHandle Handle; + Uint32 Key; + }; + + static constexpr Uint32 PackLevelKey(MobileGL::TextureUploadTarget uploadTarget, Uint level) { + return (static_cast(uploadTarget) << 16) | (level & 0xFFFFu); + } + static constexpr MobileGL::TextureUploadTarget UnpackUploadTarget(Uint32 key) { + return static_cast(key >> 16); + } + static constexpr Uint UnpackLevel(Uint32 key) { return key & 0xFFFFu; } + + static Entry& EntryFor(Vector& table, MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (slot >= table.size()) table.resize(slot + 1); + return table[slot]; + } + static Uint16 MaskOf(const Vector& table, MGPipeHandle handle) { + const SizeT slot = handle.Slot; + if (slot >= table.size() || table[slot].Gen != handle.Gen) return Uint16{0}; + return table[slot].BindMask; + } + // A SLOT THE ALLOCATOR HAS HANDED OUT AGAIN CARRIES ITS PREDECESSOR'S ENTRY, and every + // field in it is a lie about the new object (m4). The sticky BindMask is the one that + // bites: the framebuffer emitter ORs RENDER_TARGET / DEPTH_STENCIL into these entries + // whether or not the texture family is on, so a recycled slot's new texture inherited + // the dead one's mask and its first descriptor said so. The generation is what + // distinguishes them and the reset is here because AcquireTexture is the one door. + // + // IT IS THE BELT, NOT THE PATH (final review C-2): the death helper forwards to + // NoteTextureDied, which retires the entry - drain entries, cache reference, latches, + // mask - at the death itself. This stays for a slot whose death this emitter was never + // told about, and drops the same reference if one is still standing. + void RetireIfRecycled(Entry& entry, MGPipeHandle handle) { + if (entry.Gen == handle.Gen) return; + MGPipeSamplerCsoCacheInstance().Release(entry.BuiltinSampler); + entry = Entry{}; + } + + // resource_create, and the LATCH IS TAKEN ONLY WHERE THE CREATE ACTUALLY WENT OUT + // (D-I1, c0b): MGPipeHandleIsPublished is what the death helper reads, so latching on + // a call the applier refused would emit a resource_destroy for a record that does not + // exist - a refused call the applier asserts on in a verify build. + void PublishCreate(MGPipeKind kind, MGPipeHandle handle, Entry& entry, + const MGPResourceDesc& desc) { + Bool accepted = false; + Bool dispatched = false; + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + dispatched = true; + accepted = MGPipeRouteResourceCreate(desc); + } + if (dispatched && !accepted) return; + MGPipeNoteHandlePublished(kind, handle); + entry.LastDesc = desc; + entry.HasLastDesc = true; + } + + // `level` is null for the whole resource and a key for exactly one level; every caller + // says which (final review C-1), and RepublishMask's null is deliberate - a mask move + // replaces no storage at all. + static Bool ApplyRespecify(const MGPResourceDesc& desc, const MGPRespecifiedLevel* level) { + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + return MGPipeRouteResourceRespecify(desc, nullptr, level); + } + (void)level; + return false; + } + + // One respecify with one key, and the refusal self-heal beside it. THE SECOND HALF OF + // THE SELF-HEAL, and the publication latch cannot give it: the latch answers "did a + // create for this handle GO OUT", which stays true after + // MGPipeApplierReleaseObjectRecords has dropped every object record - the scope a + // served context's teardown takes while the frontend objects live on in the share + // group. The applier's REFUSAL is the only signal that says "I hold nothing for this + // handle", and the acceptance return is what makes it visible from here at all. One + // retry, never a loop: a descriptor the applier refuses on its own merits (a target + // that names no resource kind) is refused again and the flags stay set. + Bool RespecifyOnce(ITextureObject& texture, MGPipeHandle handle, Entry& entry, const MGPResourceDesc& desc, + const MGPRespecifiedLevel* key, MGPipeHandle viewOf, MGPipeHandle bufferHandle, + Uint64 bufOffset, Uint64 bufSize) { + Bool accepted = ApplyRespecify(desc, key); + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + if (!accepted) { + const MGPResourceDesc healDesc = MGPipeBuildTextureResourceDesc( + texture, handle, entry.BindMask, /*storageDefined=*/false, viewOf, bufferHandle, + bufOffset, bufSize); + NoteDesc(healDesc, /*isCreate=*/true); + PublishCreate(MGPipeKind::Texture, handle, entry, healDesc); + accepted = ApplyRespecify(desc, key); + } + } + return accepted; + } + + static void NoteRespecified(Entry& entry, const MGPResourceDesc& desc, Bool accepted) { + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + if (!accepted) return; + } + entry.LastDesc = desc; + entry.HasLastDesc = true; + } + + // THE METADATA RESPECIFY (ID-18 M4). Every storage-defining field is the stored + // descriptor's own byte for byte - the record IS entry.LastDesc with a new mask - which + // is what makes the applier classify it as a metadata update: the descriptor is + // replaced so the mask and the hint take their new values, the serial advances, and no + // pending upload is dropped. + void RepublishMask(MGPipeKind kind, MGPipeHandle handle, Entry& entry) { + if (!MGPipeTextureSubsystemEnabled()) return; + // Nothing has described this object to the applier yet, so the create or the first + // respecify carries the new mask anyway - both read entry.BindMask. + if (!entry.HasLastDesc || !MGPipeHandleIsPublished(kind, handle)) return; + MGPResourceDesc desc = entry.LastDesc; + desc.BindMask = entry.BindMask; + desc.ImageBindableHint = (entry.BindMask & kMGPipeBindShaderImage) != 0 ? 1 : 0; + if (std::memcmp(&entry.LastDesc, &desc, sizeof(desc)) == 0) return; + NoteDesc(desc, /*isCreate=*/false); + // A NULL LEVEL, DELIBERATELY (wire-v3 §5 item 6): a mask move replaces no storage, + // and the applier classifies the identical storage fields as a metadata update + // that drops nothing. A key here would name a level this call did not touch. + NoteRespecified(entry, desc, ApplyRespecify(desc, nullptr)); + } + + void NoteDesc(const MGPResourceDesc& desc, Bool isCreate) { + m_lastDesc = desc; + if (isCreate) { + ++m_creates; + } else { + ++m_respecifies; + } + } + + // True when the level's record went out and its flags may be cleared. + Bool EmitOneLevel(const DrainEntry& pending, Uint64& bytes) { + ITextureObject* texture = ResolveTexture(pending.Handle); + if (texture == nullptr) return true; // the object is gone; nothing is owed + auto* mipmap = MG_State::GLState::AsMipmapTexture(texture); + if (mipmap == nullptr) return true; // a buffer texture has no level to upload + const MobileGL::TextureUploadTarget uploadTarget = UnpackUploadTarget(pending.Key); + const Uint level = UnpackLevel(pending.Key); + if (!mipmap->IsStorageDirty(uploadTarget, level)) return true; + + const IntVec3 levelSize = mipmap->GetMipmapTexelSize(uploadTarget, level); + const SizeT levelBytes = mipmap->GetMipmapByteSize(uploadTarget, level); + const MGPipeLevelPitch pitch = MGPipeLevelPitchOf(levelSize, levelBytes); + if (pitch.BytesPerTexel == 0) return false; // no storage yet; the texels are still owed + const void* shadow = mipmap->MapMipmapData(uploadTarget, level); + if (shadow == nullptr) return false; + + const MGPBox unionBox = MGPipeBoxOfDirtyRegion(mipmap->GetStorageDirtyRegion(uploadTarget, level)); + if (unionBox.W == 0 || unionBox.H == 0 || unionBox.D == 0) return false; + + // THE REGION LIST BEHIND THE BOX. 0 is legal and means "the union box is the whole + // story" - it covers a single rect (identical to the box by construction), more + // rects than the cap, and a summed area so close to the box's that one big upload + // beats many small ones. The invariant that makes the server's choice safe is that + // the two describe the SAME texels: every rect lies inside the box, and their union + // is the box. + MG_State::GLState::MipmapDirtyRegion rects[MG_State::GLState::MipmapStorage::kMaxDirtyRects]; + const SizeT rectCount = mipmap->GetStorageDirtyRects( + uploadTarget, level, rects, MG_State::GLState::MipmapStorage::kMaxDirtyRects); + m_regions.clear(); + m_regions.reserve(rectCount); + for (SizeT i = 0; i < rectCount; ++i) { + m_regions.push_back( + MGPipeBuildSubRegion(MGPipeBoxOfDirtyRegion(rects[i]), levelSize, pitch)); + } + + m_lastSubData = MGPSubData{}; + m_lastSubData.Res = pending.Handle; + // The contract's packer takes two Uint32s (c0c keeps MGPipeTypes.h backend-neutral), + // so the frontend enumeration is widened here rather than there. + m_lastSubData.Target = MGPipePackSubDataTarget( + static_cast(MGPipeResourceTargetForTextureTarget(texture->GetTarget())), + static_cast(uploadTarget)); + m_lastSubData.Level = static_cast(level); + // ALWAYS 1 ON THE CLIENT SIDE. The conversion fallbacks (the packed-norm, widened + // and fallback upload preparers) are the server's and run there, so the bytes this + // record declares ARE the level shadow. The server clears the flag internally when + // it converts, which is the `uploadData == mipData` pointer comparison turned into + // a carried fact. + m_lastSubData.SourceIsVerbatimLevelShadow = 1; + m_lastSubData.UnionBox = unionBox; + m_lastSubData.RegionCount = static_cast(m_regions.size()); + // "THIS RECORD DOES NOT DECLARE ITS BLOB", which is what a monolith emission is: + // Seg is kMGHostSpanSegNone, Offset IS the address of the level shadow base, and a + // zero Size means the destination box is what bounds the write. A non-zero Size + // that did not match the record's own byte count would be Fatal{ProtocolCorruption} + // on the far side, and a texture record's byte count is the server's to compute + // once it has picked box-or-rects. + m_lastSubData.Blob.Seg = kMGHostSpanSegNone; + m_lastSubData.Blob.Offset = static_cast(reinterpret_cast(shadow)); + m_lastSubData.Blob.Size = 0; + + // THE REGION LIST IS THE CALL'S VARIABLE TAIL AND IT IS HANDED OVER (M1). v1 built + // m_regions, wrote its size into RegionCount and passed nothing, so on this base - + // where the applier's tail exists - every scattered upload would have declared N + // regions and supplied none (the applier faults on exactly that). A null tail is + // correct ONLY for the whole-level shape, where RegionCount is 0. + Bool accepted = false; + Bool dispatched = false; + if constexpr (MGPipeTextureRecordsReachTheApplier()) { + dispatched = true; + // `levelBytes` closes CONTRACT-P5 table 1 row 7's open half. The record still + // declares Blob.Size 0 on the monolith arm - where the applier reads the + // companion pointer and the destination box bounds the write - but under split + // the staged run needs a length, and the comment above already says what it + // is: "the bytes this record declares ARE the level shadow". The regions' own + // SrcOffsets index into exactly that run. + accepted = MGPipeRouteResourceSubData(m_lastSubData, shadow, + static_cast(levelBytes), + m_regions.empty() ? nullptr : m_regions.data()); + } + ++m_subDatas; + if (MG_Util::PipeStats::Enabled()) { + MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::ClientTextureUploadEmissions, 1); + } + bytes += sizeof(MGPSubData) + m_regions.size() * sizeof(MGPSubRegion); + // THE CLIENT CLEARS ITS OWN FLAG ONLY FOR A LEVEL THE APPLIER ACCEPTED (D-D5 step 1 + // read literally; ID-18 M3). v1 cleared on DISPATCH - and, with the wired constant + // still 0, even on a call the `if constexpr` had discarded - so any refusal left the + // server with nothing and the client with a clean flag, and since MG_Impl contains + // no reader of a texture's dirty state the level simply stopped updating for the + // life of the texture. The two refusal paths are invisible from here without this + // answer: a dead or stale handle is a counted no-op and a corrupt record is a Fatal + // that deliberately moves no counter. + // + // A REFUSED LEVEL STAYS DIRTY AND STAYS ON THE DRAIN LIST, which is the safe + // direction and self-heals: the ordinary cause is a record the applier does not + // hold, and the next respecify's self-healing create gives it one. It is LOUD + // because a permanently refused level would otherwise re-emit once per verb for + // ever with nothing to show for it. + if (dispatched && !accepted) { + ++m_refusedSubDatas; + MGLOG_E_ONCE("MGPipe: resource_subdata for texture {slot=%u, gen=%u} level %u was refused; " + "the level stays dirty and is retried at the next validate point", + pending.Handle.Slot, pending.Handle.Gen, static_cast(level)); + return false; + } + mipmap->MarkStorageDirty(uploadTarget, level, false); + return true; + } + + Vector m_textures; + Vector m_renderbuffers; + Vector m_drain; + Vector m_regions; + Bool m_draining = false; + + MGPResourceDesc m_lastDesc{}; + MGPTextureParams m_lastParams{}; + MGPSubData m_lastSubData{}; + + Uint64 m_creates = 0; + Uint64 m_respecifies = 0; + Uint64 m_paramSets = 0; + Uint64 m_subDatas = 0; + Uint64 m_refusedSubDatas = 0; + Uint64 m_refusedParamSets = 0; + Uint64 m_samplerCsoPayloadBytes = 0; + mutable Uint64 m_deadResolves = 0; + }; + + inline MGPipeTextureEmitter& MGPipeTextureEmitterInstance() { + // NEVER DESTROYED, for MGPipeTrackerInstance()' reason, and this one is not + // hypothetical: ~TextureObjectBase reaches this emitter through the death helper's + // RecordIsPublished / NoteRecordDestroyed pair, which read and WRITE its tables. A + // destroyed emitter answers out of a freed Vector and the write grows it. + static MGPipeTextureEmitter* emitter = new MGPipeTextureEmitter(); + return *emitter; + } + + inline Bool MGPipeTextureSubsystemEnabled() { + return (kMGPipeWiredTextureSubsystem & kMGPipeSubsystemTextureResources) != 0 && + (MG_Config::Features.PipePush & kMGPipeSubsystemTextureResources) != 0; + } + + // --------------------------------------------------------------------------------- + // WHAT USED TO BE HERE, AND WHY IT IS NOT (c0b, ID-13) + // --------------------------------------------------------------------------------- + // + // v1 carried eight free functions - MGPipeMintAndCreateTexture, MGPipeEmitTextureRespecify, + // MGPipeEmitTextureParams, MGPipeNoteTextureLevelDirty, MGPipeMintAndCreateRenderbuffer, + // MGPipeEmitRenderbufferRespecify and the two ...ResourceDestroy halves - because at the + // contract tag MG_Pipe/PipeMutation.h declared no texture birth hook and + // MG_Impl/Pipe/PipeFill.cpp's death helpers hard-coded `published = false`. Every one of + // them is now A's: + // + // * the four MINTS and the nine EMISSIONS are declared in PipeMutation.h and defined in + // PipeFill.cpp, which gates them on FamilyIsLive(bit, kMGPipeWiredTextureSubsystem) and + // forwards to this class through ForwardWhenWired. MGPipeEmitTextureParams in + // particular was a NAME COLLISION - the contract declares that exact signature - so + // keeping the inline definition here would not have compiled at all; + // * step 1 of the death order is inside MGPipeEmitTextureDestroyAndFree / + // ...RenderbufferDestroyAndFree, which read MGPipeHandleIsPublished and emit the + // resource_destroy themselves, so the destructors call ONE helper and not two - and + // since the final review's C-2 the helper then forwards to NoteTextureDied / + // NoteRenderbufferDied above, so no ITextureObject* survives its object in this table + // and no dead level survives on the drain list; + // * the publication latch is PipeFill.cpp's {kind, slot, gen} table, written by + // PublishCreate above and read by those helpers. + // + // The self-healing create in EmitResourceRespecify stays this file's: c0b provides no such + // path and it is what repairs a texture born while the subsystem bit was clear. +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/Tracker.h b/MobileGL/MG_Impl/Pipe/Tracker.h new file mode 100644 index 000000000..8ed7316d2 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/Tracker.h @@ -0,0 +1,936 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/Tracker.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The frontend state tracker (ARCHITECTURE.md 5.2, P2 brief D4). +// +// WHERE IT RUNS. Not above MGP_FILL and not in the GL setter: MGPipeValidateForVerb, the +// one statement MGP_FILL already expands to before every gBackendFunctionsTable.GL call +// (PipeFill.h). Blaze3D brackets every batch with glEnable/glDisable(GL_BLEND), so a +// setter that pushed would push twice per batch for a state the batch may not even read; +// the validate point coalesces the whole bracket into the two draws that observe it +// (ARCHITECTURE.md 5.1). +// +// WHAT IT DOES. One Uint32 dirty mask per verb, one bit per row of ARCHITECTURE.md 5.2, +// computed by comparing a shutter against what the tracker last pushed. P2 emitted for bits +// 0..4 (the value-class ones); P3a adds bits 5, 9 and 10 - the vertex-input family - and P4a +// adds SEVEN: 6, 7 and 8 (the program family), 11 (the framebuffer) and 12, 13 and 14 (the +// three unit sets). Only bits 15, 16 and 17 - the const-buffer, shader-buffer and +// stream-output sets - are still computed, latched and counted without an emitter, so the +// per-bit fire rate is a measurement rather than a plan and their fields go through the +// residual fill until P4b. +// +// P4a NARROWS NOTHING AND WIDENS THREE THINGS, and every one of them was an UNDER-FIRE that +// only became reachable once the bit gained an emitter: +// (1) bit 11's shutter gains the READ framebuffer binding slot's version, because +// set_framebuffer_state is emitted per bound TARGET and a glBindFramebuffer( +// GL_READ_FRAMEBUFFER, ...) moved no shutter at all before; +// (2) bit 13's gains the TEXTURE BIND generation, because glBindSampler moves that one and +// not the sampling-resolution one, so bind_sampler_states could not see a sampler bind; +// (3) bits 6/7/8 - and with them bit 14's program half - read the EFFECTIVE program source +// instead of GetCurrentProgram() alone, which is null for the whole life of a bound +// separable program pipeline, so a re-composited pipeline reached no program emitter. +// Over-firing is free; all three of those were the other direction. +// +// WHY EVERY SHUTTER OVER-FIRES. A bit that fires too often costs one extra push. A bit +// that fires too rarely renders stale, and ARCHITECTURE.md 13.2 names that as the +// dangerous direction precisely because the P1 verify comparator cannot see it for +// object-class state (it compares those by identity only). So each shutter below is +// deliberately coarser than the state it guards - five bits share one buffer aggregate, +// the framebuffer bit fires on any attachment write anywhere - and the narrowing is P3's +// work, paid for with the fire rates this file publishes. +// +// NO TIMER LIVES HERE. ROADMAP.md forbids committing hot-path instrumentation; the +// absolute ns/draw comes from DriverBench, which times whole frames from outside the +// library (P2 brief D17). The only counting is the per-bit fire tally, behind +// PipeStats::Enabled() like every other counting site in the tree. +// +// HEADER-ONLY, and that is an ownership decision rather than a design one: the P2 brief +// asks for Tracker.{h,cpp}, but the root CMakeLists.txt that would have to name a new .cpp +// belongs to package A and is frozen behind the p2/contract tag. Everything here is +// included by exactly one translation unit in the library (MG_Impl/Pipe/PipeFill.cpp) plus +// the unit tests, so inline costs nothing. Splitting it back out is one list(APPEND) line. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include + +#include + +namespace MobileGL::MG_Pipe { + + // One bit per row of the ARCHITECTURE.md 5.2 table, hand-written rather than generated: + // the list is design, not derived data, and the generator has nothing to derive it from. + enum class MGPipeDirty : Uint32 { + // ---- value class: P2 emits for these five ---- + NewRenderState = 0, // RenderState::m_version -> set_dynamic_state + NewPipelineState, // RenderState::m_pipelineStateVersion -> create/bind_render_state + NewPixelPack, // PixelStoreParameters (pack) -> set_pixel_pack_state + NewPatchState, // the patch trio, NaN legal -> set_patch_state + NewVertexAttribDefaults, // glVertexAttrib* defaults -> set_vertex_attrib_defaults + // ---- value class: NEW_VERTEX_ELEMENTS is emitted from P3a and the other three from + // P4a - the program family, one subsystem, three bits because the frontend moves them + // as three separate events ---- + NewVertexElements, // the bound VAO's attribute configuration -> create/bind_vertex_elements + NewShader, // the current program's link version -> create/bind_shader_state, + // set_draw_program, set_dispatch_program (P4a) + NewShaderBindings, // image units, block bindings, uniform write set (P4a) + NewGlobalConstants, // the default-uniform-block image -> set_global_constants (P4a) + // ---- object class. THE FIRST TWO ARE P3a's, not P3b/P4b's: the roadmap puts + // set_vertex_buffers and set_index_buffer in the same phase as the vertex-elements + // trio, and this comment said otherwise until the commit that wired them. THE NEXT + // FOUR ARE P4a's. The last three are still computed and counted only, until P4b. ---- + NewVertexBuffers, // -> set_vertex_buffers (P3a) + NewIndexBuffer, // -> set_index_buffer (P3a) + NewFramebuffer, // -> set_framebuffer_state, per bound target (P4a) + NewSamplerViews, // -> set_sampler_views (P4a) + NewSamplers, // -> bind_sampler_states (P4a) + NewShaderImages, // -> set_shader_images (P4a) + NewConstBuffers, + NewShaderBuffers, + NewSoTargets, + Count, + }; + + inline constexpr SizeT kMGPipeDirtyCount = static_cast(MGPipeDirty::Count); + static_assert(kMGPipeDirtyCount <= 32, "the dirty mask is a Uint32"); + + inline constexpr Uint32 MGPipeDirtyBit(MGPipeDirty bit) { + return Uint32{1} << static_cast(bit); + } + + // The five P2 emits for. Each phase's constant survives as the next phase's A/B control + // and as what a test compares the subsystem map against, so none of them is edited in + // place when a later phase takes more bits over. + inline constexpr Uint32 kMGPipeDirtyEmittedAtP2 = + MGPipeDirtyBit(MGPipeDirty::NewRenderState) | MGPipeDirtyBit(MGPipeDirty::NewPipelineState) | + MGPipeDirtyBit(MGPipeDirty::NewPixelPack) | MGPipeDirtyBit(MGPipeDirty::NewPatchState) | + MGPipeDirtyBit(MGPipeDirty::NewVertexAttribDefaults); + + // The three P3a adds: the vertex-input family, all on one subsystem. + inline constexpr Uint32 kMGPipeDirtyEmittedAtP3a = + kMGPipeDirtyEmittedAtP2 | MGPipeDirtyBit(MGPipeDirty::NewVertexElements) | + MGPipeDirtyBit(MGPipeDirty::NewVertexBuffers) | MGPipeDirtyBit(MGPipeDirty::NewIndexBuffer); + + // The SEVEN P4a adds, across FOUR subsystems: bits 6/7/8 are the program family, 11 the + // framebuffer, and 12/13/14 the sampler-view / sampler-state / image-unit sets. Added + // rather than edited into the two above, for the reason those two exist: each phase's + // constant survives as the next phase's A/B control and as what a test compares the + // subsystem map against. + // + // EVERY ONE OF THESE SHUTTERS WAS ALREADY COMPUTED, LATCHED AND COUNTED before P4a; what + // P4a adds is an emitter for them. That is why this is a one-line constant and not seven + // new shutters - and it is also why the two narrowings below are stated as requirements. + inline constexpr Uint32 kMGPipeDirtyEmittedAtP4a = + kMGPipeDirtyEmittedAtP3a | MGPipeDirtyBit(MGPipeDirty::NewShader) | + MGPipeDirtyBit(MGPipeDirty::NewShaderBindings) | + MGPipeDirtyBit(MGPipeDirty::NewGlobalConstants) | + MGPipeDirtyBit(MGPipeDirty::NewFramebuffer) | MGPipeDirtyBit(MGPipeDirty::NewSamplerViews) | + MGPipeDirtyBit(MGPipeDirty::NewSamplers) | MGPipeDirtyBit(MGPipeDirty::NewShaderImages); + + // The THREE P5e adds, all one subsystem (kMGPipeSubsystemBufferBindings): the indexed + // buffer binding points, whose three bits have been computed and counted since P2 and have + // named no subsystem since. Added rather than edited in, for the reason above. + // + // NewSoTargets rides the same bit even though set_stream_output_targets stays UNEMITTED + // for the whole of P5e (XFB is lockstep by escalation, CONTRACT-P5E.md §5.7): the bit is + // "which A/B switch owns this family's legacy arm", and an operator clearing bit 13 has to + // get the whole binding-point family's frontend walk back rather than two thirds of it - + // the rule P3a's three and P4a's two triples already state. + inline constexpr Uint32 kMGPipeDirtyEmittedAtP5e = + kMGPipeDirtyEmittedAtP4a | MGPipeDirtyBit(MGPipeDirty::NewConstBuffers) | + MGPipeDirtyBit(MGPipeDirty::NewShaderBuffers) | MGPipeDirtyBit(MGPipeDirty::NewSoTargets); + + inline constexpr const char* kMGPipeDirtyNames[kMGPipeDirtyCount] = { + "NEW_RENDER_STATE", + "NEW_PIPELINE_STATE", + "NEW_PIXEL_PACK", + "NEW_PATCH_STATE", + "NEW_VERTEX_ATTRIB_DEFAULTS", + "NEW_VERTEX_ELEMENTS", + "NEW_SHADER", + "NEW_SHADER_BINDINGS", + "NEW_GLOBAL_CONSTANTS", + "NEW_VERTEX_BUFFERS", + "NEW_INDEX_BUFFER", + "NEW_FRAMEBUFFER", + "NEW_SAMPLER_VIEWS", + "NEW_SAMPLERS", + "NEW_SHADER_IMAGES", + "NEW_CONST_BUFFERS", + "NEW_SHADER_BUFFERS", + "NEW_SO_TARGETS", + }; + + // Which runtime MOBILEGL_PIPE_PUSH subsystem bit gates a dirty bit's emission. Zero for + // a bit P2 does not emit, which is what makes "the bitmask is a true per-subsystem A/B" + // literally true rather than approximately. + inline constexpr Uint64 MGPipeSubsystemForDirty(MGPipeDirty bit) { + switch (bit) { + case MGPipeDirty::NewRenderState: + case MGPipeDirty::NewPipelineState: + return kMGPipeSubsystemRenderState; + case MGPipeDirty::NewPixelPack: + return kMGPipeSubsystemPixelPack; + case MGPipeDirty::NewPatchState: + return kMGPipeSubsystemPatchState; + case MGPipeDirty::NewVertexAttribDefaults: + return kMGPipeSubsystemVertexAttribDefaults; + // P3a's three, all one subsystem: create/bind_vertex_elements, set_vertex_buffers + // and set_index_buffer are the vertex-input family and an operator switching it off + // has to get the whole family's legacy arm, not two thirds of it. + // PipeFill.cpp's SubsystemForEmitter carries the pairing static_asserts. + case MGPipeDirty::NewVertexElements: + case MGPipeDirty::NewVertexBuffers: + case MGPipeDirty::NewIndexBuffer: + return kMGPipeSubsystemVertexInput; + // P4a's seven, across four subsystems. FOUR AND NOT ONE for P3a's reason one level + // out: a framebuffer path that regressed, a texture path that regressed, a sampler + // path that regressed and a program path that regressed are four different findings. + // + // The program family is three bits because the frontend moves them separately - a + // relink, a binding change and a uniform write are three events - but one subsystem, + // because an operator switching programs off has to get the whole family's legacy arm. + // Same for the three unit sets: create_sampler_state, create_sampler_view and the + // three kVarTail sets are one family, and half of it is not a control. + case MGPipeDirty::NewShader: + case MGPipeDirty::NewShaderBindings: + case MGPipeDirty::NewGlobalConstants: + return kMGPipeSubsystemPrograms; + case MGPipeDirty::NewFramebuffer: + return kMGPipeSubsystemFramebuffer; + case MGPipeDirty::NewSamplerViews: + case MGPipeDirty::NewSamplers: + case MGPipeDirty::NewShaderImages: + return kMGPipeSubsystemSamplers; + // P5e's three, one subsystem (MG_Remote/CONTRACT-P5E.md §1): the indexed buffer + // binding points. Bits 15/16/17 have been computed and counted since P2 and have named + // no subsystem since - "the remaining bits have no call of their own until P4b", which + // the default arm below used to say for them. set_shader_buffers is the call, and + // set_stream_output_targets stays unemitted while riding the same A/B bit, for the + // reason kMGPipeDirtyEmittedAtP5e states. + // + // NAMING THE SUBSYSTEM IS NOT THE SAME AS EMITTING. The emission gate is five + // conjuncts (PipeFill.cpp's `wants()`), one of which is kMGPipeWiredSubsystems - and + // the buffer-binding family's wired constant is 0 until the package that gives the + // emitter its body sets it, exactly as P4a's four families were. So this map moves + // here, inert, and nothing is emitted and no field is skipped on its account yet. + case MGPipeDirty::NewConstBuffers: + case MGPipeDirty::NewShaderBuffers: + case MGPipeDirty::NewSoTargets: + return kMGPipeSubsystemBufferBindings; + // NO BIT NAMES kMGPipeSubsystemTextureResources, and that is deliberate rather than an + // omission: the texture and renderbuffer resource_* calls and set_texture_params are + // dispatched from the GL entry points that cause them - a constructor, a storage + // definition, a glTexParameter - not from a dirty walk, exactly as P3a's buffer family + // is. Bit 10 gates those dispatch sites; there is no dirty bit to map onto it and + // there must not be one, or the emission would be gated twice and disagree with itself. + default: + // Every dirty bit now names a subsystem; the arm stays because the switch is over + // a value cast from an index and a future bit must not fall off the end. + return 0; + } + } + + // A COMPOSITE shutter, for the bits whose "did anything move" is more than one counter. + // It is a hash, so two different states can in principle collide and cost a MISSED fire. + // The five bits P2 emits for are never composed - they are widened counters and byte + // compares, neither of which can collide. + // + // P3a's three ARE composed, so the risk is now real rather than academic, and it is + // accepted with its size stated: each mix takes a 64-bit input into a 64-bit + // accumulator, so two DIFFERENT vertex configurations collide with probability ~2^-64 + // per pair, and the inputs are a monotone lifetime id, a monotone configuration version + // and a widened slot version - none of which an application can steer. The alternative, + // comparing the whole 32-attribute configuration byte for byte on every verb, is the + // per-draw cost the shutter exists to avoid. The narrowing that removes the composition + // for bit 10 - its own slot version plus the bound object's identity - is what this + // phase already did to the one shutter that was composed over an unrelated aggregate. + inline constexpr Uint64 MGPipeMixShutter(Uint64 accumulator, Uint64 value) { + accumulator ^= value + 0x9e3779b97f4a7c15ull + (accumulator << 6) + (accumulator >> 2); + return accumulator; + } + + // A Uint16 counter widened at the TRACKER boundary, never in MG_State + // (ARCHITECTURE.md 5.2: MG_State is not changed for this). A decrease is a wrap and adds + // 65536. A wrap is harmless locally - one extra re-push, never a missed one - which is + // exactly what TrackerTest.WrapAroundRePushesButNeverMisses pins. + // + // THE ONE CASE IT CANNOT SEE, stated because "never a missed push" is otherwise stronger + // than what is true: the wrap test is `now < m_last`, so a counter that advances by + // EXACTLY 65536 (or a multiple) between two walks reads as unchanged. That needs 65536 + // render-state mutations inside one verb boundary, and it is pre-existing in class - + // both backends already compare raw Uint16 versions the same way - so P2 records it + // rather than widening MG_State's counters, which ARCHITECTURE.md 5.2 rules out. + class MGPipeWidenedCounter { + public: + Uint64 Observe(Uint16 now) { + if (m_started && now < m_last) m_high += 0x10000ull; + m_started = true; + m_last = now; + return m_high + now; + } + void Reset() { + m_high = 0; + m_last = 0; + m_started = false; + } + + private: + Uint64 m_high = 0; + Uint16 m_last = 0; + Bool m_started = false; + }; + + class MGPipeTracker { + public: + using GLContext = MG_State::GLState::GLContext; + + // The dirty walk. Compares every shutter against what was last pushed, LATCHES the + // new values, counts the fires per verb class, and returns the mask. Latching here + // rather than after emission is deliberate: a bit whose subsystem is switched off is + // not emitted, but its fields are then still pulled by the residual fill, so the + // pushed block is correct either way and a bit can never fire twice for one change. + Uint32 Update(GLContext& ctx, MGPipeVerbClass verbClass) { + // A different context is a different server: nothing the tracker latched about + // the old one says anything about this one, and the first walk on a fresh + // context must publish a COMPLETE state rather than an increment. + if (m_context != &ctx) { + Reset(); + m_context = &ctx; + } + const Bool wasPrimed = m_primed; + + Uint64 now[kMGPipeDirtyCount]; + const RenderStateParameters& render = ctx.GetRenderStateParameters(); + + // ---- bits 0..1: the two Uint16 render-state counters, widened HERE ---- + now[Index(MGPipeDirty::NewRenderState)] = + m_renderStateVersion.Observe(static_cast(ctx.GetRenderStateParametersVersion())); + now[Index(MGPipeDirty::NewPipelineState)] = + m_pipelineStateVersion.Observe(static_cast(ctx.GetPipelineStateVersion())); + + // ---- bit 4 and the value-class bits 5..8 ---- + now[Index(MGPipeDirty::NewVertexAttribDefaults)] = ctx.GetAnyVertexAttribDefaultGeneration(); + + const auto& vao = ctx.GetBoundVertexArray(); + const Uint64 vaoIdentity = + vao ? MGPipeMixShutter(vao->GetLifetimeId(), vao->GetConfigVersion()) : 0; + now[Index(MGPipeDirty::NewVertexElements)] = vaoIdentity; + + // Deliberately NOT GetProgramForDraw: that joins a pending link, and the tracker + // must not force a compile just to answer "did the shader move". These version + // counters are plain members and are exactly what the backends already read + // without joining (Core.cpp, the glUseProgram half of join site J1). + // + // BUT GetCurrentProgram() ALONE IS NOT THE PROGRAM SOURCE, AND AT P4a THAT IS AN + // UNDER-FIRE. Under GL_ARB_separate_shader_objects an application drives + // `glUseProgram(0); glBindProgramPipeline(P)`, and m_currentProgram is then null + // for the whole life of that pipeline (Core.cpp, GetProgramForDraw's second half): + // all three of these shutters read 0 == 0 forever, so after the first walk on a + // fresh context - the one !m_primed fires unconditionally - bits 6, 7 and 8 never + // fire again however the pipeline is restaged. + // + // WHILE NOTHING WAS EMITTED FOR THEM THAT WAS INVISIBLE, which is how it survived + // to P4a: GetProgramForDraw is emitted-and-still-pulled, the residual fill copies + // it at every verb, and DirtySurface.def rules BindProgramPipelineObject + // kPulledEveryVerb for exactly that reason - the backend still receives the right + // SharedPtr and nothing renders wrong. The moment P4a emits off these bits it + // stops being invisible: glUseProgramStages rebuilds the composite, EmitShaderState + // is never called again, so the new composite gets no ShaderCso handle and no + // create_shader_state while set_draw_program keeps naming the previous one - a + // program the handle protocol never announced, which is exactly the seam-defect + // class P3a spent a phase closing. And bit 8 never firing means + // set_global_constants is never sent for a pipeline draw at all, where the pull + // rescues nothing. + // + // SO THE SHUTTER READS THE EFFECTIVE SOURCE: the program in use when there is one, + // and the bound pipeline when there is not. What it reads OF that pipeline is the + // pair ComputeDrawProgramSignature() is built from - each stage program's lifetime + // id and LINK version - so bit 6 fires exactly when GetProgramForDraw would hand + // back a different composite, which is exactly when a new ShaderCso handle has to + // be minted. Those are the same non-artefact fields the plain-program arm above + // reads, and the ones Core.cpp calls out as not passing through ProgramObject's + // join gate, so the "must not force a compile" rule survives intact: no join, no + // flatten, no Link(). + // + // THE PIPELINE NAME IS MIXED IN because two pipelines can carry the same stage set + // and each caches its OWN composite object, so the signature alone would let a + // glBindProgramPipeline between two such pipelines pass without a fire. What that + // does NOT close is a name RECYCLED (glDeleteProgramPipelines + + // glGenProgramPipelines) back onto the same stage programs at the same link + // versions with no other program-family change in between: a ProgramPipelineObject + // has no lifetime id and no wire object at all - DirtySurface.def says so where it + // rules MarkProgramPipelineForDeletion kUnpublishedDestroy - so there is nothing + // else here to mix it with. Recorded rather than quietly left: closing it needs a + // generation counter on the frontend object, which is an MG_State change and not + // this file's to make. + const auto& program = ctx.GetCurrentProgram(); + Uint64 shader = 0; + Uint64 bindings = 0; + Uint64 constants = 0; + Uint64 programImages = 0; + // THE PROGRAM INPUT OF THE PROGRAM-RESOLVED VIEW SET (P4a fable seam F-1). + // set_sampler_views is resolved for the program in use (SamplerEmit.h: the sampler + // uniform's TYPE picks which of a unit's targets is the view) and the emitter + // memoises that resolution on (lifetime id, link version, backend state version). A + // shutter that read only the texture generations therefore missed a glUseProgram: + // `glBindTexture x N; glUseProgram(P1); draw; glUseProgram(P2); draw` moved nothing + // bit 12 read, so the view set stayed P1's - and E's record epoch, keyed on the two + // set serials, then never rebuilt the texture sync list for P2 either. This value is + // that memo key, and bit 12 mixes it in below: over-firing costs one re-resolution + // the set-hash suppressor absorbs, under-firing left the record describing the + // previous program's units. + Uint64 opaqueUnits = 0; + if (program) { + shader = MGPipeMixShutter(program->GetLifetimeId(), program->GetLinkVersion()); + bindings = MGPipeMixShutter( + MGPipeMixShutter(MGPipeMixShutter(program->GetImageUnitVersion(), + program->GetBackendStateVersion()), + program->GetBlockBindingVersion()), + program->GetUniformWriteSetVersion()); + constants = MGPipeMixShutter(program->GetLifetimeId(), program->GetUBOContentVersion()); + // THE IDENTITY IS MIXED IN (P4a fable seam F-2), exactly as the pipeline arm + // below mixes stageLinks into its half: the counter alone is a per-program + // number two programs routinely share - 0 == 0 for any pair that never moved an + // image unit through glUniform1i, and 0 == 0 against no program at all - so a + // glUseProgram between them fired nothing, set_shader_images' window stayed the + // previous program's, and a program whose only image is a BUFFER image (E's + // SD-4: nothing else moves between the bind and the dispatch) never reached the + // record at all. + programImages = MGPipeMixShutter(shader, program->GetImageUnitVersion()); + opaqueUnits = MGPipeMixShutter(shader, program->GetBackendStateVersion()); + } else if (const auto& pipeline = ctx.GetBoundProgramPipeline(); pipeline) { + using Pipeline = MG_State::GLState::ProgramPipelineObject; + // THE FIELDS ARE READ DIRECTLY RATHER THAN THROUGH THE TWO FUNCTIONS THAT + // ALREADY PACK THEM, and that is a gate constraint, not a preference. Calling + // ComputeDrawProgramSignature() / ComputeUniformMirrorVersions() would say + // "the same pairs the composite cache and the uniform-mirror gate compare" + // far better than this loop does - but gen_pipe_dirty_surface.py derives a + // shutter by following each accessor to the member it returns, and both of + // those build a LOCAL array and return that, which it cannot place. A shutter + // naming them is UNRESOLVED, and then every DirtySurface.def row that names + // bits 6, 7, 8 or 14 loses its verdict - including the derivation that is the + // only mechanism able to catch the next under-fire here. So the pairs are + // spelled out, and the two static_asserts below are what say they must stay in + // step with the functions they mirror. + static_assert(sizeof(Pipeline::DrawProgramSignature) == + 2 * Pipeline::kGraphicsStageCount * sizeof(Uint64), + "bit 6 reads the {lifetimeId, linkVersion} pair per graphics " + "stage that ComputeDrawProgramSignature packs"); + static_assert(sizeof(Pipeline::UniformMirrorVersions) == + 2 * Pipeline::kGraphicsStageCount * sizeof(Uint64), + "bits 7 and 8 read the four counters per graphics stage that " + "ComputeUniformMirrorVersions packs"); + + // Bit 6 is the pipeline's identity plus the composite cache key. Bits 7 and 8 + // add the per-program state, which under a pipeline is written to the STAGE + // programs - glUniform* addresses the pipeline's active program, + // glProgramUniform* and the two block-binding calls address a named one - and + // only reaches the composite through RefreshCompositeUniforms. Bit 14's half + // takes the image-unit generation, which is its own counter for the reason + // ProgramObject gives (ES forbids glUniform1i on an image uniform, so Espryt + // BAKES the unit into the ESSL it generates and only a regeneration honours a + // change) and which D-G4 asks this shutter to keep reading as a FRONTEND + // counter rather than any server-side epoch. + // + // STAGELINKS IS MIXED INTO ALL THREE OF THE OTHERS, ON PURPOSE. A composite + // REBUILD hands back a brand-new ProgramObject with an empty default uniform + // block and no backend state at all - SetCachedDrawProgram clears the mirror + // versions with it - so a shutter watching only the per-stage state counters + // would let a rebuilt composite inherit the bindings, the constants and the + // image units of the one it replaced. + Uint64 stageLinks = static_cast(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(stage)); + if (!staged) continue; + stageLinks = MGPipeMixShutter( + MGPipeMixShutter(stageLinks, staged->GetLifetimeId()), staged->GetLinkVersion()); + stageState = MGPipeMixShutter( + MGPipeMixShutter(MGPipeMixShutter(stageState, staged->GetBackendStateVersion()), + MGPipeMixShutter(staged->GetUBOContentVersion(), + staged->GetBlockBindingVersion())), + staged->GetUniformWriteSetVersion()); + stageImages = MGPipeMixShutter(stageImages, staged->GetImageUnitVersion()); + stageOpaque = MGPipeMixShutter(stageOpaque, staged->GetBackendStateVersion()); + } + shader = stageLinks; + stageState = MGPipeMixShutter(stageLinks, stageState); + bindings = MGPipeMixShutter(stageState, stageImages); + constants = stageState; + programImages = MGPipeMixShutter(stageLinks, stageImages); + opaqueUnits = MGPipeMixShutter(stageLinks, stageOpaque); + } + now[Index(MGPipeDirty::NewShader)] = shader; + now[Index(MGPipeDirty::NewShaderBindings)] = bindings; + now[Index(MGPipeDirty::NewGlobalConstants)] = constants; + + // =========================================================================== + // THE RECORD-FIELD -> SETTER -> SHUTTER TABLE FOR THE SEVEN P4a BITS. + // + // THE RULE (P4a fable seam audit, section C.1): every field of every emitted + // record names the frontend setter that changes it, and that setter moves a + // counter the emitting bit's shutter reads - or the emission is unconditional at + // the setter (the resource_* family, set_texture_params). A record field whose + // setter moves no shutter input is a stale record with nothing to refuse: c0d + // (bit 13 without the bind generation), SD-0 (an image re-bind), F-1 (the + // program behind the view set), F-2 (the program behind the image window) and + // F-3 (an attached object's storage) were all this one class. DirtySurface.def + // cannot catch it - it maps MUTATORS to bits and cannot see that a DERIVED field + // depends on a mutator whose row is another family's - so the table lives here, + // beside the shutters, and a row is added whenever a record gains a field. + // + // bit 6 create/bind_shader_state, set_draw/dispatch_program (ProgramEmit.h) + // fields: Cso, StageMask, GlobalUboSize, the artefact blob refs, the two + // bound handles + // setters: glUseProgram (m_currentProgram), glLinkProgram (link version), + // glBindProgramPipeline / glUseProgramStages (pipeline name + + // per-stage {lifetime id, link version}) + // shutter: lifetime id x link version, or stageLinks under a pipeline + // bit 7 the program's bindings (image units, block bindings, uniform write set) + // setters: glUniform1i on an opaque uniform (backend state version, image + // unit version), glUniformBlockBinding / glShaderStorageBlockBinding + // (block binding version), any glUniform* (uniform write set) + // shutter: the four per-program counters, x stageLinks under a pipeline + // bit 8 set_global_constants: ShaderCso, Version, the default-block image + // setters: any glUniform* on the default block (UBO content version), + // glUseProgram (lifetime id) + // shutter: lifetime id x UBO content version, or stageState + // bit 11 set_framebuffer_state: Fbo, Color[8]/Depth/Stencil/ReadSurface + // (Res, Kind, InternalFormat, TextureTarget, Layered, Level, Layer, + // UploadTarget), DrawBuffers[8], Width/Height/Layers/Samples/ + // FixedSampleLocations, IsDefault, Complete, Target + // setters: glFramebufferTexture*/glFramebufferRenderbuffer, glDrawBuffer(s), + // glReadBuffer, glFramebufferParameteri (the attachment + // aggregate); glBindFramebuffer (the two binding slot versions); + // AND a storage redefinition of an ATTACHED texture or + // renderbuffer - glTexImage*/glTexStorage*/glTexBuffer/ + // glTextureView/glRenderbufferStorage* - because InternalFormat, + // TextureTarget, the extent, Samples and Complete are INLINED at + // emission (D-C1): those bump the attachment aggregate from the + // object's PipePublishDescriptor (F-3) + // shutter: attachment aggregate x draw bind version x read bind version + // bit 12 set_sampler_views: per unit {View, Texture} + // setters: glBindTexture / glActiveTexture (bind generation), a texture's + // or a sampler object's parameters (SamplesAsIncompleteTexture - + // the params aggregate), an upload that defines a level (content + // aggregate), the default texture's image appearing (bind + // generation, TextureObject.cpp); AND the program in use - + // glUseProgram, a relink, glUniform1i on a sampler uniform (which + // unit a uniform's TYPE resolves) - F-1 + // shutter: content x params x bind generation x opaqueUnits + // bit 13 bind_sampler_states: per unit the sampler CSO handle + // setters: glBindSampler (bind generation, c0d), glSamplerParameter* / + // glTexParameter* (params aggregate + sampling resolution), + // glDeleteSamplers (bind generation) + // shutter: params x sampling resolution x bind generation + // bit 14 set_shader_images: per unit {Res, InternalFormat, Layer, Level, + // Layered, Access} over the program's image-unit window + // setters: glBindImageTexture (bind generation, SD-0), a texture's + // content/params, glUniform1i on an image uniform (image unit + // version); AND the program in use - glUseProgram, a relink - + // F-2 + // shutter: content x params x bind generation x programImages + // (lifetime id x link version x image unit version) + // =========================================================================== + + // ---- the object-class bits 9..17 ---- + const Uint64 textureContent = ctx.GetAnyTextureContentGeneration(); + const Uint64 textureParams = ctx.GetAnyTextureParamsGeneration(); + // P5e (sb): the buffer CONTENT aggregate is no longer read here at all. It was + // bits 15/16/17's whole shutter and it answered the wrong question for every one of + // them (see those bits below); the aggregate itself stays, because + // MGPipeAggregate::BufferChange is still one of the six the walk reports and a + // counter with no reader is a different removal from a shutter with a better input. + + // Bit 9. The VAO attribute aggregate mixed with the bound VAO's identity is + // already exact for the SET - it is bumped by all three Bump*Version functions, + // which are the only writers of an attribute's format, buffer or enable state - + // and a driver-id re-mint that moves no client counter is caught server-side by + // the backend's own id generation. + // + // THE PENDING BASE INSTANCE IS MIXED IN, and this is a deviation from the design + // note that said "keep the shutter" (recorded in client-v1.md): the draw's + // baseInstance is now an EXPLICIT field of set_vertex_buffers and a + // ContentHash input, and it moves neither the attribute aggregate nor the VAO + // identity. Without it here, a draw whose only change is its base instance would + // never reach the emitter at all and the server would keep the previous fetch + // shift - which is the same silently-wrong-geometry the backend's + // baseInstanceDirty flag exists to prevent, one level further out. It fires + // extra only on the draws that actually carry one. + now[Index(MGPipeDirty::NewVertexBuffers)] = MGPipeMixShutter( + MGPipeMixShutter(ctx.GetAnyVaoAttributeGeneration(), vaoIdentity), m_pendingBaseInstance); + // Bit 10, NARROWED (P3a, D-I). It used to mix the whole buffer-CONTENT aggregate + // with the VAO identity and therefore fired on any buffer write anywhere; what + // it guards is one binding slot, so it now reads that slot's own version and the + // identity of what is bound to it. The version is a WRAPPING Uint16 bumped only + // on a real change, so it goes through the widened counter at this boundary; the + // bound object's lifetime id joins it because identity is what closes the wrap + // hole. The VAO identity stays in the mix because the element slot BELONGS to + // the bound VAO - switching VAOs switches slots. + Uint64 indexShutter = 0; + if (vao) { + const auto& indexSlot = vao->GetIndexBufferBindingSlot(); + const auto& indexObject = indexSlot.GetBoundObject(); + indexShutter = MGPipeMixShutter(m_indexSlotVersion.Observe(indexSlot.GetVersion()), + indexObject ? indexObject->GetLifetimeId() : 0); + } + now[Index(MGPipeDirty::NewIndexBuffer)] = MGPipeMixShutter(vaoIdentity, indexShutter); + // Bit 11, WIDENED AT P4a AND THIS IS A REQUIREMENT RATHER THAN AN OPTION. The + // shutter observed the DRAW binding slot only, so glBindFramebuffer( + // GL_READ_FRAMEBUFFER, ...) moved nothing at all - which was harmless while + // nothing was emitted for the bit and is an UNDER-FIRE the moment P4a emits + // set_framebuffer_state per bound target (D-C2): the read record would never be + // sent and the server's ReadSurface would stay the previous framebuffer's. Over- + // firing costs one extra push; under-firing renders stale, and this file's own + // rule is that under-firing is the dangerous direction. + // + // A STORAGE REDEFINITION OF AN ATTACHED OBJECT MOVES THIS SHUTTER (P4a fable seam + // F-3), and the sentence that stood here - "a renderbuffer respecify is still + // invisible here, and deliberately so ... closed by emitting resource_respecify + // straight from the storage entry point" - was true of the RESOURCE record only. + // set_framebuffer_state inlines each attachment's InternalFormat, TextureTarget, + // extent, Samples and Complete (D-C1: "so the four cross-object masks fall out at + // push time with no lookup"), so `glTexImage2D(tex, RGB8); attach; draw; + // glTexImage2D(tex, RGBA8); draw` left the FRAMEBUFFER record saying RGB8 while the + // resource record said RGBA8, and the handle arm answered its alpha-widening, + // snorm-clamp and integer masks from the stale copy where the legacy arm re-read + // the frontend at the same re-sync - a proven arm divergence on a public-GL + // sequence. The fix is at the SETTER, not here: TextureObjectBase::PipePublish + // Descriptor and RenderbufferObject::PipePublishDescriptor - the one funnel every + // storage-defining entry point of either object takes, push-only - bump the + // attachment aggregate this shutter already reads. No counter is added to either + // object (G1), nothing widens this shutter onto the texture-content aggregate (which + // would fire the 304-byte record build on every glTexSubImage2D), and a storage + // definition of an UNATTACHED object over-fires it exactly once at load time. + // + // AND A TRAP THE NEXT NARROWING WOULD WALK INTO, recorded here because it is + // invisible from the shutter: FramebufferObject::SetDrawBuffer versions the VALUE + // being written rather than the index being written TO - it calls + // BumpAttachmentVersion(buffer). The object version and the aggregate still move, + // so THIS shutter is safe; a narrower one built on m_attachmentVersions would not + // be, and P4a must not build one. + now[Index(MGPipeDirty::NewFramebuffer)] = MGPipeMixShutter( + MGPipeMixShutter( + ctx.GetAnyFramebufferAttachmentGeneration(), + m_framebufferBind.Observe( + ctx.GetFramebufferBindingSlot(FramebufferTarget::Draw).GetVersion())), + m_readFramebufferBind.Observe( + ctx.GetFramebufferBindingSlot(FramebufferTarget::Read).GetVersion())); + // Bit 12 reads FOUR things (F-1): the two texture aggregates, the bind generation + // and the program input computed above. The params aggregate is here because + // SamplerEmit.h drops a unit's view to null when SamplesAsIncompleteTexture says so, + // and that predicate reads the effective sampler's filters - a glTexParameteri( + // MIN_FILTER) that completes a texture fired bit 13 and not this one, so the entry + // stayed null. The program input is here because the set is resolved FOR THE + // PROGRAM IN USE, and a glUseProgram alone moved nothing this shutter read. + now[Index(MGPipeDirty::NewSamplerViews)] = MGPipeMixShutter( + MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), ctx.GetTextureBindGeneration()), + opaqueUnits); + // Bit 13, WIDENED AT P4a FOR BIT 11's REASON and found the same way. glBindSampler + // moves NEITHER half of what this used to read: GL_Sampler.cpp's BindSampler_State + // goes through NoteTextureUnitTouched and TextureUnit::SetSamplerObject, and both + // of those bump the TEXTURE BIND generation - bit 12's. The only two writers of + // BumpSamplingResolutionGeneration are PARAMETER changes (SamplerObject.cpp, + // TextureObject.cpp). So `glBindSampler(3, a); draw; glBindSampler(3, b); draw` + // fired bit 12 twice and bit 13 not once, and the server's BoundSamplerStates[3] + // went on naming a's CSO: wrong filtering, with nothing able to see it, because + // bind_sampler_states has no pulled twin to fall back on the way the view set does. + // + // MIXING THE GENERATION IN IS THE FIX RATHER THAN A SECOND GATE ON THE EMITTER, + // because that generation is what the unit SET is derived from: a sampler bind + // changes which sampler state applies at a unit, and a texture bind changes it too + // whenever the unit carries no sampler object and the texture's BUILT-IN sampler is + // what applies. Keeping it one shutter per bit is also what keeps the per-subsystem + // A/B and the per-bit fire tallies meaning what they say - a bit gated on another + // bit's shutter measures neither. The extra fires a plain texture bind now costs + // are swallowed by the emitter's own set-hash suppressor, which MGPipeTypes.h makes + // mandatory for every kVarTail set for this exact traffic. + now[Index(MGPipeDirty::NewSamplers)] = MGPipeMixShutter( + MGPipeMixShutter(textureParams, ctx.GetSamplingResolutionGeneration()), + ctx.GetTextureBindGeneration()); + now[Index(MGPipeDirty::NewShaderImages)] = MGPipeMixShutter( + MGPipeMixShutter(MGPipeMixShutter(textureContent, textureParams), programImages), + ctx.GetTextureBindGeneration()); + // ---- bits 15/16/17, REWRITTEN AT P5e (sb, MG_Remote/CONTRACT-P5E.md §5.6) ---- + // + // ALL THREE USED TO READ `buffers` - the buffer CONTENT aggregate - AND THAT WAS + // WRONG IN BOTH DIRECTIONS AT ONCE. Over: any glBufferSubData anywhere fired all + // three, which is the same width bit 10 was narrowed out of at P3a. Under, and this + // is the half that mattered: glBindBufferBase / glBindBufferRange mutate a binding + // point through a returned reference, which moves the slot's own Uint16 version and + // NOTHING the content aggregate reads - so + // `glBindBufferBase(UNIFORM,1,A); draw; glBindBufferBase(UNIFORM,1,B); draw` fired + // no bit at all. Harmless while nothing was emitted for these three; an + // under-fire the moment set_shader_buffers is, and under-firing renders stale, + // which this file's own rule calls the dangerous direction. Same defect class as + // P4a's glBindSampler hole (c0d), closed the same way: the shutter reads the + // generation the mutator actually moves (BufferState::NoteBindPointChanged). + // + // THE CONTENT AGGREGATE LEAVES ALL THREE and is not replaced by anything: whether + // the BYTES behind a bound buffer moved is the resource family's question, answered + // server-side by the resource record's own Serial, and the record these bits emit + // carries {handle, offset, size} - none of which a glBufferSubData changes. A base + // binding's extent is the one thing that could, and it does not travel resolved: + // Size is kMGPipeWholeBuffer and the server re-resolves it at use (§5.6). + // + // BIT 15 MIXES THE PROGRAM IDENTITY IN, for bit 12's and bit 14's reason one family + // over (fable seams F-1/F-2): the uniform window is resolved FOR THE PROGRAM IN USE + // - the UBO loop indexes it by the program's own block bindings - so a glUseProgram + // alone must re-open it. `shader` is that identity in both arms (lifetime id x link + // version, or stageLinks under a pipeline). + // + // BIT 16 IS TWO TARGETS, one bit: a storage-buffer bind and an atomic-counter bind + // are both "the shader's writable binding points moved", they are emitted together + // at the same validate point, and splitting them would buy one suppressed record on + // a workload that binds one without the other. + // + // BIT 17 KEEPS THE TRANSFORM-FEEDBACK GENERATION beside the new bind-point one: + // the capture points are span-scoped state latched at Begin, so when a span opens + // matters as much as what is bound. Nothing is emitted for it this phase (§5.7). + now[Index(MGPipeDirty::NewConstBuffers)] = MGPipeMixShutter( + ctx.GetBufferBindPointGeneration(BufferTarget::Uniform), shader); + now[Index(MGPipeDirty::NewShaderBuffers)] = MGPipeMixShutter( + ctx.GetBufferBindPointGeneration(BufferTarget::ShaderStorage), + ctx.GetBufferBindPointGeneration(BufferTarget::AtomicCounter)); + now[Index(MGPipeDirty::NewSoTargets)] = MGPipeMixShutter( + ctx.GetBufferBindPointGeneration(BufferTarget::TransformFeedback), + ctx.GetTransformFeedbackGeneration()); + + Uint32 dirty = 0; + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + // Bits 2 and 3 are handled below: they are BitwiseEqual shutters, not + // counters, so they have no entry in `now`. + if (i == Index(MGPipeDirty::NewPixelPack) || i == Index(MGPipeDirty::NewPatchState)) { + continue; + } + if (!m_primed || now[i] != m_lastPushed[i]) dirty |= Uint32{1} << static_cast(i); + m_lastPushed[i] = now[i]; + } + + // ---- bit 2: the PACK half of the pixel store, BitwiseEqual ---- + const PixelStoreParameters pack = ctx.GetPixelStoreParameters(false); + if (!m_primed || std::memcmp(&pack, &m_pack, sizeof(pack)) != 0) { + dirty |= MGPipeDirtyBit(MGPipeDirty::NewPixelPack); + m_pack = pack; + } + + // ---- bit 3: the patch trio, BitwiseEqual, and NaN IS LEGAL ---- + // A NaN outer level is a legal glPatchParameterfv value and must compare equal to + // itself (ARCHITECTURE.md 5.2). Float equality says it is not; memcmp says it is, + // which is the whole reason this is a byte compare. + PatchTrio patch{}; + patch.PatchVertices = render.PatchVertices; + for (SizeT i = 0; i < 4; ++i) patch.Outer[i] = render.PatchDefaultOuterLevel[i]; + for (SizeT i = 0; i < 2; ++i) patch.Inner[i] = render.PatchDefaultInnerLevel[i]; + if (!m_primed || std::memcmp(&patch, &m_patch, sizeof(patch)) != 0) { + dirty |= MGPipeDirtyBit(MGPipeDirty::NewPatchState); + m_patch = patch; + } + + m_primed = true; + m_freshlyPrimed = !wasPrimed; + m_lastDirty = dirty; + + if (MG_Util::PipeStats::Enabled()) { + const SizeT cls = static_cast(verbClass); + ++m_walks[cls]; + for (SizeT i = 0; i < kMGPipeDirtyCount; ++i) { + if (dirty & (Uint32{1} << static_cast(i))) ++m_fires[i][cls]; + } + } + return dirty; + } + + // Context teardown, server reset, a unit test's fixture. The next Update returns + // every bit set, which is what makes the first verb on a fresh context publish a + // complete state rather than an increment. Deliberately does NOT clear the fire + // tallies: they are a per-run measurement, not per-context state. + // + // AND IT DELIBERATELY DOES NOT CLEAR m_pendingBaseInstance. Everything else this + // function clears is a LATCH describing what the server was last told; the pending + // base instance is THIS CALL'S ARGUMENT, written by the draw entry point one + // statement before MGP_FILL and not yet read by anybody. Update() calls Reset() from + // inside itself whenever the current GLContext pointer moves, so clearing it here + // meant that `eglMakeCurrent(ctxB); glDrawArraysInstancedBaseInstance(..., 7)` put a + // BaseInstance of 0 on the wire - one silently mis-shifted instanced draw per context + // switch, on the emulation path, with nothing to catch it. The value is cleared by the + // verb that consumes it (PipeFill.cpp's step 3, and its no-context early return) and + // by MGPipeLeaveVerb, which is where a per-call argument belongs. + void Reset() { + std::memset(m_lastPushed, 0, sizeof(m_lastPushed)); + m_renderStateVersion.Reset(); + m_pipelineStateVersion.Reset(); + m_framebufferBind.Reset(); + m_readFramebufferBind.Reset(); + m_indexSlotVersion.Reset(); + m_pack = PixelStoreParameters{}; + m_patch = PatchTrio{}; + m_staged = RenderStateParameters{}; + m_stagedAttribs = AttribDefaults{}; + m_context = nullptr; + m_lastDirty = 0; + m_primed = false; + m_freshlyPrimed = false; + } + + void ResetCounters() { + std::memset(m_fires, 0, sizeof(m_fires)); + std::memset(m_walks, 0, sizeof(m_walks)); + } + + Uint64 FireCount(MGPipeDirty bit, MGPipeVerbClass verbClass) const { + return m_fires[Index(bit)][static_cast(verbClass)]; + } + Uint64 FireCount(MGPipeDirty bit) const { + Uint64 total = 0; + for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_fires[Index(bit)][i]; + return total; + } + Uint64 WalkCount(MGPipeVerbClass verbClass) const { + return m_walks[static_cast(verbClass)]; + } + Uint64 WalkCount() const { + Uint64 total = 0; + for (SizeT i = 0; i < kMGPipeVerbClassCount; ++i) total += m_walks[i]; + return total; + } + + Uint32 LastDirty() const { return m_lastDirty; } + Bool Primed() const { return m_primed; } + // True when the LAST Update was the first one after a Reset - a fresh context, or a + // server reset. The emission step reads it to send a COMPLETE state rather than an + // increment against a staging mirror that describes a context that is gone. + Bool FreshlyPrimed() const { return m_freshlyPrimed; } + + // "What the server has" (P2 brief D8). set_dynamic_state sends the dynamic chunks + // that differ from this, which is the chunk-level suppressor; a chunk that + // memcmp-matches is not sent at all. + RenderStateParameters& Staged() { return m_staged; } + const RenderStateParameters& Staged() const { return m_staged; } + + // The same mirror for the 32 glVertexAttrib* defaults: set_vertex_attrib_defaults + // names only the attributes that differ from it, which is the var-tail's own + // suppressor underneath D11's set-hash one. + using AttribDefaults = Array; + AttribDefaults& StagedAttribDefaults() { return m_stagedAttribs; } + const AttribDefaults& StagedAttribDefaults() const { return m_stagedAttribs; } + + // ---- P3a D-H2: the draw's vertex-FETCH base instance ---- + // + // It lives HERE rather than in a file static because bit 9's shutter has to see it: + // an ambient process global cannot cross a pushed boundary, and the value is now an + // explicit field of set_vertex_buffers and an input to its content hash, so a draw + // whose only change is its base instance has to reach the emitter. Set immediately + // before the fill at the three *BaseInstance draw entry points; CONSUMED and cleared + // by the validate point once it has been emitted, so a plain draw that follows one + // sees 0 again. + // + // THE CLEAR THAT ACTUALLY RUNS IN PRODUCTION IS THE VALIDATE POINT'S. MGPipeLeaveVerb + // clears it too, but no GL entry point calls MGPipeLeaveVerb - only MG_Test's + // ScopedPipeVerb and TrackerTest do - so the production guarantee is entirely + // PipeFill.cpp's, on BOTH of its exits: the end of step 3, and the no-live-context + // early return that skips step 3 altogether. Reset() deliberately does not clear it + // (see there): it is this call's argument, not a latch. + void SetPendingBaseInstance(Uint32 baseInstance) { m_pendingBaseInstance = baseInstance; } + Uint32 PendingBaseInstance() const { return m_pendingBaseInstance; } + void ClearPendingBaseInstance() { m_pendingBaseInstance = 0; } + + private: + static constexpr SizeT Index(MGPipeDirty bit) { return static_cast(bit); } + + struct PatchTrio { + Uint PatchVertices; + Float Outer[4]; + Float Inner[2]; + }; + + Uint64 m_lastPushed[kMGPipeDirtyCount]{}; + MGPipeWidenedCounter m_renderStateVersion; + MGPipeWidenedCounter m_pipelineStateVersion; + // The draw framebuffer BINDING slot version, widened for the same reason: a Uint16 + // that wrapped would let a composite shutter repeat and cost a missed fire. + MGPipeWidenedCounter m_framebufferBind; + // P4a: the READ framebuffer binding slot's version, its own counter for the same + // reason the draw one exists. Two counters rather than one over both slots: a single + // widened counter fed two independent Uint16s reads a decrease as a wrap on every + // alternation and would add 65536 per switch, which costs nothing in correctness + // (over-firing) but makes the high word meaningless. + MGPipeWidenedCounter m_readFramebufferBind; + // The BOUND VAO's element-array slot version, widened for the same reason. One + // counter over a slot that changes with the bound VAO: a stale high word can only + // ADD a fire, never drop one, and the VAO identity in the same mix is what makes a + // switch between two VAOs differ whatever their slot versions read. + MGPipeWidenedCounter m_indexSlotVersion; + Uint32 m_pendingBaseInstance = 0; + // Bits 2 and 3 are BitwiseEqual shutters, not counters. + PixelStoreParameters m_pack{}; + PatchTrio m_patch{}; + + RenderStateParameters m_staged{}; + AttribDefaults m_stagedAttribs{}; + + const void* m_context = nullptr; + Uint32 m_lastDirty = 0; + Bool m_primed = false; + Bool m_freshlyPrimed = false; + + Uint64 m_fires[kMGPipeDirtyCount][kMGPipeVerbClassCount]{}; + Uint64 m_walks[kMGPipeVerbClassCount]{}; + }; + + // ONE attribute default, flattened onto the wire (P2 brief D10, AMENDED at P5c rv). A + // named function rather than four lines inside the emitter because this flattening is the + // whole correctness question of set_vertex_attrib_defaults: a CurrentVertexAttributeValue + // is one value in three views and GLContext converts NUMERICALLY between them, so four + // words alone are not the value - glVertexAttrib4f(loc, 1.5f, ...) leaves 1 in intValue + // and 0x3FC00000 in floatValue. Since rv the record carries ALL THREE VIEWS VERBATIM + // (MGPAttribValue::FloatView/IntView/UintView, CONTRACT-P5C.md §5.3) and the applier writes + // each view from its own array; ValueClass is the record of which view the application + // wrote directly, kept for the comparator and for readers - the applier no longer needs + // it to rebuild anything. + inline void MGPipeFillAttribValue(Uint32 location, + const MG_State::GLState::CurrentVertexAttributeValue& value, + Uint32 writtenClass, MGPAttribValue& out) { + out = MGPAttribValue{}; + out.Location = location; + out.ValueClass = static_cast(writtenClass); + static_assert(sizeof(out.FloatView) == sizeof(value.floatValue), + "MGPAttribValue's views are four words each"); + static_assert(sizeof(out.IntView) == sizeof(value.intValue) && + sizeof(out.UintView) == sizeof(value.uintValue), + "MGPAttribValue's views are four words each"); + std::memcpy(out.FloatView, value.floatValue.data(), sizeof(out.FloatView)); + std::memcpy(out.IntView, value.intValue.data(), sizeof(out.IntView)); + std::memcpy(out.UintView, value.uintValue.data(), sizeof(out.UintView)); + } + + // The monolith's one tracker. Under split there is one per client context; the context + // identity check inside Update is what makes the single instance safe today. + inline MGPipeTracker& MGPipeTrackerInstance() { + // NEVER DESTROYED, for MGPipeSlots()' reason (MG_Impl/Pipe/SlotAllocator.cpp). The + // rule is stated over the SET of MGPipe process singletons rather than over the two + // that a frontend destructor reaches today: which of them a destructor reaches is a + // property of the emitters, and the emitters change (C-1 added a second reaching + // path in one commit). One allocation per process, no destructor to lose - this type + // has none - and nothing can then answer a late call out of freed storage. + static MGPipeTracker* tracker = new MGPipeTracker(); + return *tracker; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_Impl/Pipe/VertexInputEmit.h b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h new file mode 100644 index 000000000..716fb81e8 --- /dev/null +++ b/MobileGL/MG_Impl/Pipe/VertexInputEmit.h @@ -0,0 +1,445 @@ +// MobileGL - MobileGL/MG_Impl/Pipe/VertexInputEmit.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#pragma once +#include + +// The CLIENT side of P3a's vertex-input family (brief D-G, D-H, D-I): the bound VAO's +// format as create/bind_vertex_elements, its buffers as set_vertex_buffers with an explicit +// baseInstance, and its element binding as set_index_buffer. +// +// UNLIKE THE RESOURCE FAMILY, these three emit at the VALIDATE POINT, from +// MGPipeValidateForVerb's step 3 in the fixed order elements -> buffers -> index. That is +// the ordinary rule (ARCHITECTURE.md 5.1); the resource family is the one exception to it. +// +// THE CSO IS IDENTITY-ADDRESSED, NOT CONTENT-ADDRESSED (D-G1, a recorded deviation from +// ARCHITECTURE.md's 1024-entry content-addressed scheme). One handle per frontend +// VertexArrayObject, minted off its lifetime id, and create_vertex_elements is RE-ISSUED on +// the same handle whenever the configuration moves - legal, because MGPipeHandle::Gen +// increments only on slot reuse and never on a respecify. Espryt has no vertex-elements CSO +// to share: its twin owns one driver VAO name plus 64 scratch buffer ids, which two frontend +// VAOs cannot share, so content addressing would be strictly slower on the only backend this +// phase touches. P7 adds the hash-probe-memcmp layer above these same three calls when +// Magma's VertexInputStateFactory takes the CSO over. +// +// WHAT THE UNIT GATE READS. G6 is "the emitted blob + set + index record reproduce exactly +// what the backend's VAO twin reads from the frontend today, field by field, for all 32 +// slots", and G7 is a scripted control that stops the conversion copying ONE field and +// expects the suite to go red NAMING it. So the conversion is a pure function per field +// (MGPipeBuildVertexAttribWire / MGPipeBuildVertexBindingPointWire) and the staging buffers +// the emitter builds into are readable afterwards - the emitter passes m_blob and m_entries +// straight to the applier, so "what was emitted" costs no copy at all. +// +// HEADER-ONLY, for the ownership reason Tracker.h states in full. +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace MobileGL::MG_Pipe { + + // --------------------------------------------------------------------------------- + // D-G2: the wire conversion, one pure function per view + // --------------------------------------------------------------------------------- + + // EVERY FIELD OF VertexAttribute THE WIRE FORM CARRIES, and nothing else: + // + // Divisor is deliberately absent - it is resolved per binding point and travels in + // MGPVertexBuffer::Divisor, which is where the backend's glVertexAttribDivisor reads + // it. Carrying it twice would let a malformed record disagree with itself. + // LegacyStride / LegacyPointer are deliberately absent - they are the + // glGetVertexAttrib* query answers and nothing but the query path reads them, so + // they stay client-side. + // Buffer is deliberately absent - identity travels in set_vertex_buffers, which is + // what keeps this record stable while the buffers under it change. + // Stride is the RESOLVED distance and a surviving 0 is MEANINGFUL: a pointer call's 0 + // was already resolved to the element size by the frontend, so a 0 here can only + // have come from the binding model, where it means every vertex reads the SAME + // element. Collapsing it back into the element size is what made + // KHR-GL43.vertex_attrib_binding.basic-input-case7/8 read past the buffer. + // IsLong travels SEPARATELY from Type == Float64: VertexAttribFormat(GL_DOUBLE) reads + // doubles and asks for them converted to float, VertexAttribLFormat keeps all 64 + // bits, and the backend's fp64 narrowing and its Adreno disabled-attribute + // workaround both key on telling the two apart. + inline MGPVertexAttribWire MGPipeBuildVertexAttribWire(const MG_State::GLState::VertexAttribute& attrib, + Uint32 bindingIndex) { + // ASSERT RATHER THAN ASSUME, in both directions, because the three narrowing casts + // below cross a package boundary: VertexArrayObject is another package's file and its + // 32-slot bound is its invariant, not this one's, so a BindingIndex of 256 would wrap + // to 0 and silently point every attribute at binding 0, and a negative Stride (the + // frontend field is a signed int) would arrive as a ~4 GiB unsigned distance. + MOBILEGL_ASSERT(bindingIndex < 256u, + "MGPVertexAttribWire::BindingIndex is a Uint8 and cannot carry %u", + static_cast(bindingIndex)); + MOBILEGL_ASSERT(attrib.Size >= 0 && attrib.Size <= 255, + "MGPVertexAttribWire::Size is a Uint8 and cannot carry %d", attrib.Size); + MGPVertexAttribWire wire{}; + wire.Offset = static_cast(attrib.Offset); + wire.Stride = static_cast(attrib.Stride); + wire.Type = static_cast(attrib.Type); + wire.Size = static_cast(attrib.Size); + wire.Enabled = attrib.Enabled ? 1 : 0; + wire.Normalized = attrib.Normalized ? 1 : 0; + wire.IsInteger = attrib.IsInteger ? 1 : 0; + wire.IsLong = attrib.IsLong ? 1 : 0; + wire.IsBgra = attrib.IsBgra ? 1 : 0; + wire.BindingIndex = static_cast(bindingIndex); + return wire; + } + + // The ARB_vertex_attrib_binding view. Its initial Stride is 16, not 0 (GL 4.6 core table + // 23.4), which is why the wire form keeps it signed and copies it verbatim. + inline MGPVertexBindingPointWire + MGPipeBuildVertexBindingPointWire(const MG_State::GLState::VertexBufferBindingPoint& point) { + MGPVertexBindingPointWire wire{}; + wire.Offset = static_cast(point.Offset); + wire.Stride = static_cast(point.Stride); + wire.Divisor = static_cast(point.Divisor); + return wire; + } + + // --------------------------------------------------------------------------------- + // D-H2.3: the content hash, WITH BaseInstance in it + // --------------------------------------------------------------------------------- + // + // A HARD REQUIREMENT, not a nicety. set_vertex_buffers is suppressed on an unchanged + // hash (SetHashSuppressor.h's SetVertexBuffers slot), so a baseInstance that moved while + // the buffer set did not would be suppressed and the server would keep the previous + // fetch shift - exactly the bug the backend's baseInstanceDirty flag exists to prevent. + inline Uint64 MGPipeVertexBufferSetContentHash(const MGPVertexBuffer* entries, Uint32 start, Uint32 count, + Uint32 baseInstance) { + Uint64 hash = XXH64(entries, static_cast(count) * sizeof(MGPVertexBuffer), 0); + hash = MGPipeMixShutter(hash, start); + hash = MGPipeMixShutter(hash, count); + hash = MGPipeMixShutter(hash, baseInstance); + return hash; + } + + // --------------------------------------------------------------------------------- + // The emitter + // --------------------------------------------------------------------------------- + + class MGPipeVertexInputEmitter { + public: + using GLContext = MG_State::GLState::GLContext; + using VertexArrayObject = MG_State::GLState::VertexArrayObject; + static constexpr SizeT kAttribs = static_cast(VertexArrayObject::MAX_VERTEX_ATTRIBS); + static constexpr SizeT kBindings = static_cast(VertexArrayObject::MAX_VERTEX_ATTRIB_BINDINGS); + static_assert(kAttribs <= kMGPipeMaxVertexAttribs && kBindings <= kMGPipeMaxVertexAttribs, + "both declared counts are bounded by kMGPipeMaxVertexAttribs"); + + // create/bind_vertex_elements. D-G3's three arms, verbatim: + // + // no VAO bound -> bind the null handle (legal, and it means + // exactly "no VAO bound") + // the bound VAO CHANGED -> (re)create if its configuration moved since + // this handle last published one, then bind + // the same VAO, configuration MOVED-> create on the SAME handle, and do NOT rebind + // + // The latch is PER HANDLE, in a slot-indexed table, so ping-ponging between two VAOs + // re-binds but never re-creates either. A Uint32 configuration version does not wrap + // in any realistic run and is compared directly; the tracker's widened counter is + // for the Uint16s and is not needed here. + Uint64 EmitVertexElements(GLContext& ctx) { + const auto& vao = ctx.GetBoundVertexArray(); + if (!vao) { + if (!MGPipeHandleIsNull(m_boundHandle)) { + MGPipeRouteBindVertexElements(HandleOnly(kMGPipeNullHandle)); + ++m_binds; + m_boundHandle = kMGPipeNullHandle; + m_boundLifetimeId = 0; + } + return 0; + } + + const Uint64 lifetimeId = vao->GetLifetimeId(); + const Uint32 configVersion = vao->GetConfigVersion(); + const MGPipeHandle handle = MGPipeSlots().Acquire(MGPipeKind::VertexElementsCso, lifetimeId); + const SizeT slot = handle.Slot; + if (slot >= m_latch.size()) m_latch.resize(slot + 1); + Latch& latch = m_latch[slot]; + + Uint64 bytes = 0; + const Bool configMoved = !latch.Published || latch.ConfigVersion != configVersion || + latch.Gen != handle.Gen; + if (configMoved) bytes += EmitCreate(*vao, handle, latch, configVersion); + if (lifetimeId != m_boundLifetimeId || m_boundHandle != handle) { + MGPipeRouteBindVertexElements(HandleOnly(handle)); + ++m_binds; + bytes += sizeof(MGPHandleOnly); + m_boundHandle = handle; + m_boundLifetimeId = lifetimeId; + } + return bytes; + } + + // set_vertex_buffers. Espryt consumes RESOLVED attributes, so the set is one entry + // per attribute slot with BindingIndex == the attribute index; Start is 0 and Count + // is the highest ENABLED attribute plus one, which is the 32-slot prefix walk the + // dirty bit is specified over. + // + // A client-memory array is Res == kMGPipeNullHandle, and that is not a hole: it is + // exactly how the server learns "this attribute is client-sourced, upload it + // yourself". Its store genuinely does not exist at this moment - the client-array + // uploader runs after PrepareForDraw, at the draw entry point - and moving that + // resolution to the client is P8's. + Uint64 EmitVertexBuffers(GLContext& ctx, Uint32 baseInstance) { + const auto& vao = ctx.GetBoundVertexArray(); + Uint32 count = 0; + if (vao) { + for (SizeT i = 0; i < kAttribs; ++i) { + if (vao->GetAttribute(static_cast(i)).Enabled) count = static_cast(i) + 1; + } + for (SizeT i = 0; i < count; ++i) { + const auto& attrib = vao->GetAttribute(static_cast(i)); + MGPVertexBuffer& entry = m_entries[i]; + entry = MGPVertexBuffer{}; + entry.Res = attrib.Buffer ? MGPipeSlots().Acquire(MGPipeKind::Buffer, + attrib.Buffer->GetLifetimeId()) + : kMGPipeNullHandle; + // D-A3's sticky mask, ORed HERE rather than only sampled at a storage op. + // This is the bit that survives the DSA idiom: a buffer defined through + // glNamedBuffer* may never be bound at any resource emission, but a draw + // that fetches from it resolves it right here, on the GL thread, at every + // draw. Sticky, so one draw is enough for the rest of its life. + MGPipeResourceTrackerInstance().NoteBoundAs(entry.Res, BufferTarget::Vertex); + // The attribute's own byte offset lives in MGPVertexAttribWire::Offset, + // so the entry's is the BINDING's, which the frontend already folded in. + entry.Offset = 0; + // Signed on the frontend, unsigned on the wire, and a negative one would + // arrive as a ~4 GiB fetch distance rather than as an error. + MOBILEGL_ASSERT(attrib.Stride >= 0, "a resolved vertex stride is never negative (%d)", + attrib.Stride); + entry.Stride = static_cast(attrib.Stride); + entry.Divisor = static_cast(attrib.Divisor); + entry.BindingIndex = static_cast(i); + } + } + + const Uint64 hash = MGPipeVertexBufferSetContentHash(m_entries.data(), 0, count, baseInstance); + if (!MGPipeSetHashSuppressorInstance().ShouldEmit(MGPipeSuppressorSlot::SetVertexBuffers, hash)) { + return 0; + } + m_lastBuffers = MGPVertexBuffers{}; + m_lastBuffers.Start = 0; + m_lastBuffers.Count = count; + // THE DRAW'S RAW value. The client never pre-shifts an offset and never learns + // whether the server emulated the shift or let GL_EXT_base_instance do it - + // emulation is server-owned. + m_lastBuffers.BaseInstance = baseInstance; + m_lastBuffers.ContentHash = hash; + MGPipeRouteSetVertexBuffers(m_lastBuffers, m_entries.data()); + ++m_bufferSets; + return sizeof(MGPVertexBuffers) + static_cast(count) * sizeof(MGPVertexBuffer); + } + + // set_index_buffer. An INDEPENDENT call, not a subset of the vertex-elements + // configuration version (D5) - the index slot is explicitly outside the VAO's + // m_configVersion, and the shutter for it is bit 10's, narrowed in Tracker.h. + // + // Offset and IndexSize are 0 here and the draw verb overrides them: at the validate + // point there is no draw to read them from, and the applier stores what it is given. + Uint64 EmitIndexBuffer(GLContext& ctx) { + const auto& vao = ctx.GetBoundVertexArray(); + m_lastIndex = MGPIndexBuffer{}; + if (vao) { + if (const auto& bound = vao->GetIndexBufferBindingSlot().GetBoundObject()) { + m_lastIndex.Res = MGPipeSlots().Acquire(MGPipeKind::Buffer, bound->GetLifetimeId()); + // The ELEMENT_ARRAY bit, and it is the one the split path keys on + // (kCapNeedsHostIndexBytes -> restart rewriting, multi-draw flattening). + // Noted at every draw for RefreshBindMask's reason: an EBO defined through + // DSA and unbound before its last respecify would otherwise never publish + // it, and getting that bit wrong is invisible in monolith. + MGPipeResourceTrackerInstance().NoteBoundAs(m_lastIndex.Res, BufferTarget::Index); + } + } + MGPipeRouteSetIndexBuffer(m_lastIndex); + ++m_indexSets; + return sizeof(MGPIndexBuffer); + } + + // ---- what a unit case reads. None of it costs a copy: the emitter builds INTO + // these and hands the applier the same pointers. ---- + const Array& LastAttributes() const { return m_attributes; } + const Array& LastBindingPoints() const { + return m_bindingPoints; + } + const MGPVertexElements& LastElements() const { return m_lastElements; } + const MGPVertexBuffers& LastVertexBuffers() const { return m_lastBuffers; } + const Array& LastEntries() const { return m_entries; } + const MGPIndexBuffer& LastIndexBuffer() const { return m_lastIndex; } + MGPipeHandle BoundHandle() const { return m_boundHandle; } + Uint64 CreateCount() const { return m_creates; } + Uint64 BindCount() const { return m_binds; } + Uint64 VertexBufferSetCount() const { return m_bufferSets; } + Uint64 IndexBufferSetCount() const { return m_indexSets; } + + // ---- C-1: "does the applier hold a record for exactly this handle?" ---- + // + // The CSO's death path (MGPipeEmitVertexElementsDestroyAndFree) needs that answer and + // MUST NOT GUESS IT FROM THE SLOT. A VertexElementsCso slot can exist with no record + // behind it, because a backend that keys its twins on the handle mints the slot itself + // (DirectGLES' BackendSlotTable::GetOrCreate -> MGPipeSlots().Acquire) whether or not + // bit 8 ever asked this client to emit anything - which is exactly what a + // MOBILEGL_PIPE_PUSH=0x7f lane runs. delete_vertex_elements on such a handle is a + // REFUSED call, and the applier's resolver asserts on a refusal + // (PipeApply.cpp's ResolveVertexElements), i.e. a stop in a verify build. + // + // Kept OUT of Reset(), unlike the create/bind latch beside it, and for the mirror + // image of Reset()'s own reason: "a fresh context is a fresh server" is true of the + // per-context half of this table, and object RECORDS are precisely what + // MGPipeApplierReset does not clear (PipeApply.h's two halves). This half tracks those + // records, so it lives exactly as long as they do. + Bool RecordIsPublished(MGPipeHandle handle) const { + if (MGPipeHandleIsNull(handle)) return false; + const SizeT slot = handle.Slot; + if (slot >= m_latch.size()) return false; + const Latch& latch = m_latch[slot]; + return latch.RecordLive && latch.RecordGen == handle.Gen; + } + + // The record named by `handle` is gone from the applier. Also drops the bound-handle + // memo when it named it, so the client's idea of BoundVertexElements and the applier's + // (which MGPipeApplyDeleteVertexElements just cleared for the same handle) stay in + // step rather than diverging until the next bind happens to correct it. + void NoteRecordDestroyed(MGPipeHandle handle) { + if (MGPipeHandleIsNull(handle)) return; + const SizeT slot = handle.Slot; + if (slot < m_latch.size() && m_latch[slot].RecordGen == handle.Gen) { + m_latch[slot] = Latch{}; + } + if (m_boundHandle == handle) { + m_boundHandle = kMGPipeNullHandle; + m_boundLifetimeId = 0; + } + } + + // A fresh context is a fresh server: the applier's records are gone, so every latch + // this emitter holds describes objects the server no longer has. Called from the + // validate point's FreshlyPrimed arm beside MGPipeApplierReset and the suppressor's + // InvalidateAll, for the same reason they are. + // + // The PER-CONTEXT half only - see RecordIsPublished above for why RecordLive/RecordGen + // survive. Re-creating a configuration the applier already holds is a bounded + // over-fire (MGPipeApplyCreateVertexElements starts the record over); forgetting that + // it holds one at all would leak the record and its slot at the object's death. + void Reset() { + for (Latch& latch : m_latch) { + latch.Published = false; + latch.Gen = 0; + latch.ConfigVersion = 0; + } + m_boundHandle = kMGPipeNullHandle; + m_boundLifetimeId = 0; + } + + void ResetCounters() { m_creates = m_binds = m_bufferSets = m_indexSets = 0; } + + private: + struct Latch { + // The PER-CONTEXT half: "has this emitter told THIS server about this handle's + // configuration". Cleared by Reset() at every make-current. + Bool Published = false; + Uint32 Gen = 0; + Uint32 ConfigVersion = 0; + // The RECORD half: "does the applier hold a create_vertex_elements record at this + // slot, for this generation". Lives as long as the record does - see + // RecordIsPublished. + Bool RecordLive = false; + Uint32 RecordGen = 0; + }; + + static MGPHandleOnly HandleOnly(MGPipeHandle handle) { + MGPHandleOnly only{}; + only.Handle = handle; + only.Kind = static_cast(MGPipeKind::VertexElementsCso); + return only; + } + + Uint64 EmitCreate(const VertexArrayObject& vao, MGPipeHandle handle, Latch& latch, Uint32 configVersion) { + // ALL 32 OF EACH, deliberately. The record DECLARES both counts and the applier + // refuses one whose counts do not describe its own blob, so a self-describing + // record is the cheap shape - and G6 is stated over all 32 slots, which a + // truncated set could not answer. It rides create_vertex_elements only, i.e. + // once per configuration change, never per draw. + for (SizeT i = 0; i < kAttribs; ++i) { + m_attributes[i] = MGPipeBuildVertexAttribWire(vao.GetAttribute(static_cast(i)), + vao.GetAttributeBindingIndex(static_cast(i))); + } + for (SizeT i = 0; i < kBindings; ++i) { + m_bindingPoints[i] = MGPipeBuildVertexBindingPointWire(vao.GetBindingPoint(static_cast(i))); + } + // Attributes first, then binding points, both ascending and contiguous. + constexpr SizeT kAttribBytes = kAttribs * sizeof(MGPVertexAttribWire); + constexpr SizeT kBindingBytes = kBindings * sizeof(MGPVertexBindingPointWire); + std::memcpy(m_blob.data(), m_attributes.data(), kAttribBytes); + std::memcpy(m_blob.data() + kAttribBytes, m_bindingPoints.data(), kBindingBytes); + + m_lastElements = MGPVertexElements{}; + m_lastElements.Cso = handle; + m_lastElements.AttributeCount = static_cast(kAttribs); + m_lastElements.BindingPointCount = static_cast(kBindings); + m_lastElements.Blob.Seg = kMGHostSpanSegNone; + m_lastElements.Blob.Offset = 0; + m_lastElements.Blob.Size = kAttribBytes + kBindingBytes; + MGPipeRouteCreateVertexElements(m_lastElements, m_blob.data()); + ++m_creates; + latch.Published = true; + latch.Gen = handle.Gen; + latch.ConfigVersion = configVersion; + // THE ONE PRODUCER of the record half: a create that reached the applier is the + // only thing that makes delete_vertex_elements a legal call for this handle. + latch.RecordLive = true; + latch.RecordGen = handle.Gen; + return sizeof(MGPVertexElements) + kAttribBytes + kBindingBytes; + } + + Array m_attributes{}; + Array m_bindingPoints{}; + Array + m_blob{}; + Array m_entries{}; + + MGPVertexElements m_lastElements{}; + MGPVertexBuffers m_lastBuffers{}; + MGPIndexBuffer m_lastIndex{}; + + Vector m_latch; + MGPipeHandle m_boundHandle = kMGPipeNullHandle; + Uint64 m_boundLifetimeId = 0; + + Uint64 m_creates = 0; + Uint64 m_binds = 0; + Uint64 m_bufferSets = 0; + Uint64 m_indexSets = 0; + }; + + // The monolith's one vertex-input emitter, beside the tracker, the CSO cache, the + // set-hash suppressor and the resource tracker. + inline MGPipeVertexInputEmitter& MGPipeVertexInputEmitterInstance() { + // NEVER DESTROYED, for MGPipeSlots()' reason (MG_Impl/Pipe/SlotAllocator.cpp), and + // this one is not hypothetical: C-1 put this emitter DIRECTLY on ~VertexArrayObject's + // path - MGPipeEmitVertexElementsDestroyAndFree asks RecordIsPublished(handle) and + // then NoteRecordDestroyed(handle), which read and WRITE m_latch. A destroyed + // emitter answers out of a freed Vector and the write grows it, i.e. an operator + // new + memcpy + operator delete on an already-freed block. + static MGPipeVertexInputEmitter* emitter = new MGPipeVertexInputEmitter(); + return *emitter; + } +} // namespace MobileGL::MG_Pipe +#endif // MOBILEGL_PIPE_PUSH diff --git a/MobileGL/MG_IntegrationTest/CMakeLists.txt b/MobileGL/MG_IntegrationTest/CMakeLists.txt index 7dad1f1dc..87e49e2e7 100644 --- a/MobileGL/MG_IntegrationTest/CMakeLists.txt +++ b/MobileGL/MG_IntegrationTest/CMakeLists.txt @@ -51,10 +51,19 @@ endif() add_executable(MobileGLIntegrationTest Main.cpp Harness/HeadlessGL.cpp + Harness/BackendCapsPeek.cpp + Harness/PipeSlotPeek.cpp + Harness/PipeApplyPeek.cpp + Harness/P4aSeamPeek.cpp + Harness/P4aFinalFixPeek.cpp + Harness/PersistentMapPeek.cpp + Harness/SplitRuntimePeek.cpp Scenarios/OrientationScenario.cpp Scenarios/CrossFrameBufferScenario.cpp Scenarios/ResidentIndexScenario.cpp Scenarios/MultiDrawScenario.cpp + Scenarios/IndexedDrawFamilyScenario.cpp + Scenarios/SyncWireScenario.cpp Scenarios/DrawParametersScenario.cpp Scenarios/AsyncCompileScenario.cpp Scenarios/XfbAfterClipDistanceScenario.cpp @@ -63,11 +72,13 @@ add_executable(MobileGLIntegrationTest Scenarios/SampledSetStalenessScenario.cpp Scenarios/ThreeChannelAttachmentScenario.cpp Scenarios/SnormAttachmentScenario.cpp + Scenarios/MonolithAttachmentClearScenario.cpp Scenarios/PipelineFailureScenario.cpp Scenarios/AdvertisedLimitsScenario.cpp Scenarios/PixelStoreSweepScenario.cpp Scenarios/PrimitiveRestartScenario.cpp Scenarios/FragCoordOriginScenario.cpp + Scenarios/ClientVertexArrayScenario.cpp Scenarios/ClearThenReadPixelsScenario.cpp Scenarios/SampleVariablesScenario.cpp Scenarios/DepthStencilReadbackScenario.cpp @@ -126,6 +137,21 @@ add_executable(MobileGLIntegrationTest Scenarios/ClearTexImageUndefinedLevelZeroScenario.cpp Scenarios/RenderbufferBlendFormatScenario.cpp Scenarios/DualSourceBlendScenario.cpp + Scenarios/PipeVerifyArmingScenario.cpp + Scenarios/PoisonOmissionScenario.cpp + Scenarios/HandleRecycleScenario.cpp + Scenarios/CsoContentAddressingScenario.cpp + Scenarios/ResourceSubsystemControlScenario.cpp + Scenarios/TextureParamsWithoutASamplerViewScenario.cpp + Scenarios/TextureUploadShapeScenario.cpp + Scenarios/ObjectSubsystemControlScenario.cpp + Scenarios/P4aSeamAuditScenario.cpp + Scenarios/P4aFinalFixScenario.cpp + # P5's two new scenarios, targets B and C of the reduced path (BRIEF-P5 4). Both are + # ORDINARY GL scenarios that run in every lane; the DirectGLES.Split. entries further down + # run the same cases with MOBILEGL_TRANSPORT=inproc. + Scenarios/TriangleScenario.cpp + Scenarios/PersistentCoherentMapScenario.cpp ) target_include_directories(MobileGLIntegrationTest PRIVATE @@ -315,6 +341,330 @@ function(mgl_itest_join_environment outVar) set(${outVar} "${joined}" PARENT_SCOPE) endfunction() +# --- what THIS TREE implements, answered by the build rather than by a person ---------- +# +# Two P2 entries assert something that only EXISTS once another P2 package has landed: +# HandleRecycleScenario's Handles arm needs a backend keyed on {slot, gen} (packages C and D), +# its AbaControl arm needs a consumer for MOBILEGL_PIPE_HANDLE_ABA_CONTROL (package D), and +# CsoContentAddressingScenario needs the client-side tracker that mints CSOs at all (package B). +# The gates package is written and merged FIRST, against the P2 contract commit, precisely so +# that the AbaControl red is on the record before either backend is touched - so for a while +# those entries have nothing to assert. +# +# The honest report for that is a SKIP naming what is missing, never a deleted registration and +# never a green that means "the thing I test does not exist yet". What decides the skip is +# THIS block, so that nobody has to remember to remove a hand-written guard: +# +# * two of the three answers are pure EXISTENCE checks, through file(GLOB CONFIGURE_DEPENDS). +# Ninja re-evaluates such a glob before every build and reconfigures only when the RESULT +# changes, so these cost nothing until the file appears - and then they arm themselves. +# * the third has to read a file's CONTENTS, because package D re-keys inside an existing +# source rather than adding one. VertexInputStateFactory.cpp is the one file both of D's +# answers live in (ComputeHash's buffer key is what the re-key changes AND what the ABA +# knob reverts), it is small, and it is watched by name - so an edit to it reconfigures and +# an edit anywhere else in the backend does not. +# +# Every verdict is printed at configure time: a marker that silently answered "no" for a tree +# that does implement the thing would turn a real gate into a permanent skip. +set(MGL_ITEST_CAPABILITY_ENV "") + +# Whether the library under test compiled the push arm. Passed in rather than inferred, because +# the two CSO counters and the cso[] bracket of the stats line are #if MOBILEGL_PIPE_PUSH: in a +# pull build there is no CSO to mint and no channel to read, so the control has nothing to say - +# and "nothing to say" must be a SKIP that names the reason, not an assertion failure about a +# missing bracket. +# +# The lanes themselves are registered in BOTH builds even so. `ctest -L integration-gpu` has to +# be name-for-name identical between the pull build and the push build (P2 gate G2), and a lane +# that exists in only one of them breaks that comparison for every future package - a much worse +# outcome than four entries that skip. +if (MOBILEGL_PIPE_PUSH) + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_PUSH_BUILD=1") +endif() + +# THE THREE MARKERS BELOW ANSWER A QUESTION ABOUT THE SOURCE TREE, so each is only a true +# statement about THIS LIBRARY while this build compiles the arm the source implements - and all +# three arms are `#if MOBILEGL_PIPE_PUSH`. A pull build has no {slot, gen} key (the slot tables +# and the re-keyed memos are push-only) and no Features.PipeHandleAbaControl at all (Config.h +# declares the field inside `#if MOBILEGL_PIPE_PUSH` and ConfigLoader parses it in the same arm). +# A source-only probe would therefore arm the PULL build's lanes the moment packages C and D +# land: the AbaControl lane would go hard red on a gate G2 requires green (the guards it means to +# defeat are still in force, so the scenario's "expect the stale pixels" assertion fails), and the +# Handles lane would report green against a library that contains no re-key at all - the +# "test that cannot fail" this scenario exists to avoid. +# +# So the whole block sits under the same `if (MOBILEGL_PIPE_PUSH)` as MGITEST_PIPE_PUSH_BUILD, and +# HandleRecycleScenario re-checks that marker before either arm asserts, so a hand-forced +# environment cannot arm an arm this build does not have either. +# +# ALL FOUR MARKERS ARE CONTENT PROBES, AND NONE OF THEM NAMES A FILE. A probe for a filename asks +# the wrong question: the owning package chooses its own file layout, so the moment it moves the +# code the probe answers "no" forever and the arm skips with a reason that has become false - a +# test quietly measuring nothing, which is the one outcome this whole scenario exists to prevent. +# The CSO probe was rewritten for exactly that reason once already; the magma probe still read one +# hard-coded .cpp, and package D already keeps one of its two Features.PipeHandleAbaControl +# consumers in a different file of the same directory (Renderer/VulkanRenderer.cpp), so it was one +# refactor away from a permanent AbaControl skip. So all four now ask "does any source in the +# directory the owning package owns name this symbol?", which is the thing each arm actually needs. +# +# Staleness cannot creep in from either side: the GLOB is CONFIGURE_DEPENDS (a file added or +# removed re-runs it) and every file it finds is appended to CMAKE_CONFIGURE_DEPENDS (an edit to +# one re-runs it). +function(mgl_itest_probe_for_symbol outVar directory symbolRegex) + file(GLOB_RECURSE mglItestProbeSources CONFIGURE_DEPENDS + "${directory}/*.h" "${directory}/*.hpp" "${directory}/*.cpp" "${directory}/*.c") + set(mglItestProbeHit "") + foreach(mglItestProbeSource IN LISTS mglItestProbeSources) + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${mglItestProbeSource}") + file(STRINGS "${mglItestProbeSource}" mglItestProbeLines REGEX "${symbolRegex}") + if (mglItestProbeLines AND NOT mglItestProbeHit) + set(mglItestProbeHit "${mglItestProbeSource}") + endif() + endforeach() + set(${outVar} "${mglItestProbeHit}" PARENT_SCOPE) +endfunction() + +# The same probe, over a CONJUNCTION: BOTH regexes matched ANYWHERE UNDER the directory, not +# necessarily in the same file. outVar is set to " + " 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 + # backend has to name it to honour MOBILEGL_PIPE_PUSH's default mask, whatever files package C + # spreads the slot tables across. + mgl_itest_probe_for_symbol(MGL_ITEST_ESPRYT_SLOTS + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectGLES" "kMGPipeSubsystemEsprytSlots") + if (MGL_ITEST_ESPRYT_SLOTS) + message(STATUS "Integration tests: DirectGLES is keyed on {slot, gen} (${MGL_ITEST_ESPRYT_SLOTS})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectGLES=1") + else() + message(STATUS "Integration tests: no DirectGLES source names kMGPipeSubsystemEsprytSlots - " + "HandleRecycle.Handles will SKIP on it") + endif() + + # The CSO counters' EMITTER. The tracker package may implement the tracker and the cache + # header-only - today it does (MG_Impl/Pipe/{Tracker,CsoCache}.h, no Tracker.cpp) - so what is + # looked for is what the control actually reads: a source emitting the two counters. + mgl_itest_probe_for_symbol(MGL_ITEST_CSO_EMITTER + "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "RenderStateCso(Mints|Binds)") + if (MGL_ITEST_CSO_EMITTER) + message(STATUS "Integration tests: the CSO counters have an emitter (${MGL_ITEST_CSO_EMITTER})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_TRACKER_PRESENT=1") + else() + message(STATUS "Integration tests: no MG_Impl/Pipe source emits RenderStateCsoMints/Binds - " + "CsoContentAddressing will SKIP") + endif() + + # DirectVulkan's Track H arm, and the ABA knob's consumer. Both over the whole backend + # directory: the re-key is subsystem 4's bit wherever package D reads it, and the knob has a + # consumer if ANY DirectVulkan source reverts a guard on it - today two do, in two files. + mgl_itest_probe_for_symbol(MGL_ITEST_MAGMA_REKEY + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/DirectVulkan" "kMGPipeSubsystemMagmaVertexInput") + if (MGL_ITEST_MAGMA_REKEY) + message(STATUS "Integration tests: DirectVulkan's vertex input is keyed on {slot, gen} " + "(${MGL_ITEST_MAGMA_REKEY})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_REKEY_DirectVulkan=1") + else() + message(STATUS "Integration tests: no DirectVulkan source names kMGPipeSubsystemMagmaVertexInput - " + "HandleRecycle.Handles will SKIP on it") + endif() + + # P3a's buffer question, and it is NOT the two above. Bits 5/6 re-keyed each backend's + # VERTEX-INPUT memos; a BUFFER only travels as a handle once the resource_* family does + # (P3a for DirectGLES, P7 for DirectVulkan), and until then a buffer's backend twin is + # still reached from the frontend BufferObject. HandleRecycle's buffer case therefore has + # a marker of its own: reading the P2 one would arm its Handles arm on a tree where + # nothing about a buffer is keyed on a handle. + # + # Probed by MGPipeResourceOps - the op table PipeApply.h declares and a backend registers - + # over each backend's whole directory, for the reason the block above gives: the owning + # package picks its own file layout, and a filename probe would answer "no" forever the + # moment it moved the code. + foreach(mglItestResourceBackend DirectGLES DirectVulkan) + mgl_itest_probe_for_symbol(MGL_ITEST_RESOURCE_OPS + "${MGL_ITEST_ROOT}/MobileGL/MG_Backend/${mglItestResourceBackend}" "MGPipeResourceOps") + if (MGL_ITEST_RESOURCE_OPS) + message(STATUS "Integration tests: ${mglItestResourceBackend} registers a resource op table " + "(${MGL_ITEST_RESOURCE_OPS})") + list(APPEND MGL_ITEST_CAPABILITY_ENV + "MGITEST_HANDLE_REKEY_RESOURCES_${mglItestResourceBackend}=1") + else() + message(STATUS "Integration tests: no ${mglItestResourceBackend} source names " + "MGPipeResourceOps - HandleRecycle.Handles' buffer case will SKIP on it") + endif() + endforeach() + + # The client-side emitter of P3a's map-persistent-roundtrips counter, which is what + # StorageBufferRegrow, LargeArenaAdoption and ResourceSubsystemControl read. Same shape and + # same reason as the CSO emitter probe above: the counter is minted in MG_Impl/Pipe (package + # B), header-only today, so the question is "does any source there emit it", not "does a + # named file exist". Until it does, mpr= is structurally zero and an assertion about it + # would be a statement about nothing. + mgl_itest_probe_for_symbol(MGL_ITEST_RESOURCE_EMITTER + "${MGL_ITEST_ROOT}/MobileGL/MG_Impl/Pipe" "MapPersistentRoundtrips") + if (MGL_ITEST_RESOURCE_EMITTER) + message(STATUS "Integration tests: map-persistent-roundtrips has an emitter " + "(${MGL_ITEST_RESOURCE_EMITTER})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_PIPE_RESOURCE_EMITTER_PRESENT=1") + else() + message(STATUS "Integration tests: no MG_Impl/Pipe source emits MapPersistentRoundtrips - " + "StorageBufferRegrow's, LargeArenaAdoption's and ResourceSubsystemControl's " + "mpr entries will SKIP") + endif() + + # 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) + message(STATUS "Integration tests: MOBILEGL_PIPE_HANDLE_ABA_CONTROL has a consumer " + "(${MGL_ITEST_MAGMA_ABA})") + list(APPEND MGL_ITEST_CAPABILITY_ENV "MGITEST_HANDLE_ABA_IMPLEMENTED=1") + else() + message(STATUS "Integration tests: no DirectVulkan source names PipeHandleAbaControl - " + "HandleRecycle.AbaControl will SKIP") + endif() +else() + message(STATUS "Integration tests: pull build - HandleRecycle.{Handles,AbaControl}, " + "CsoContentAddressing, 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 "MOBILEGL_BACKEND_TYPE=DirectGLES" ${MGL_ITEST_COMMON_ENV}) mgl_itest_join_environment(MGL_ITEST_VULKAN_ENVIRONMENT @@ -460,6 +810,20 @@ gtest_discover_tests(MobileGLIntegrationTest # emulation never runs - the ambient registration would be exercising the un-stripped path # twice and calling it coverage. With the variable set, the blocks really are emitted with no # location and the assertion is about the spelling the device gets. +# RESOURCE_LOCK, on this lane and on the three below it, and it is a bug fix rather than a +# precaution. Each of these lanes gives a WHOLE SCENARIO one MOBILEGL_LOG_FILE_PATH, and one case +# in each reads that log back to prove the pinned emulation actually armed. The library opens the +# log fopen(path, "w"), so every process in the lane TRUNCATES it - and under `ctest -j` a sibling +# case of the same lane can truncate it while the arming case is reading, which reads back as "the +# log carries no arming line" and fails a healthy lane. Measured on this tree: three runs of the +# full integration-gpu label at -j 8 produced 4 failures, 0 and 2, always one of these arming +# cases, never the same set twice. +# +# The rule stated above the verify block - a case that reads the log needs a lane whose filter +# selects it alone - would fix it by re-filtering, but that would RENAME the arming entries, and +# an existing ctest name may never disappear (gate G14). A ctest RESOURCE_LOCK named after the log +# is the same guarantee without touching a name: ctest never runs two entries holding the same lock +# at once, so the only processes that can truncate a lane's log are ones nobody is reading it for. gtest_discover_tests(MobileGLIntegrationTest TEST_PREFIX "DirectGLES.UnlocatedIoBlocks." TEST_FILTER "UnlocatedIoBlockScenario.*" @@ -467,9 +831,40 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK unlocated-io-blocks.log ENVIRONMENT "${MGL_ITEST_GLES_UNLOCATED_IO_BLOCKS_ENVIRONMENT}" ) +# P4a final review C-2: the dirty-then-delete case, with the allocator SCRIBBLING every freed +# block. The defect this pins was a client emitter resolving a dead-but-not-recycled texture +# handle to the freed ITextureObject* and calling a virtual on it from the next verb's drain; +# whether that reads the object's ghost or faults depends on what the allocator did with the +# block, so the ambient registrations above run the case as the application would see it and +# these two run it with MALLOC_PERTURB_ set, where a resolved-but-dead pointer faults rather +# than passes. Both backends: the death path is backend-neutral by ruling (ID-8). +mgl_itest_join_environment(MGL_ITEST_GLES_MALLOC_PERTURB_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MALLOC_PERTURB_=165" ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_MALLOC_PERTURB_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MALLOC_PERTURB_=165" ${MGL_ITEST_VULKAN_ENV}) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.MallocPerturb." + TEST_FILTER "P4aFinalFixScenario.ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_MALLOC_PERTURB_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.MallocPerturb." + TEST_FILTER "P4aFinalFixScenario.ADirtyTextureDeletedBeforeAnyVerbIsWalkedByTheNextDrain" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_MALLOC_PERTURB_ENVIRONMENT}" +) + # AsyncCompileScenario, with asynchronous compilation PINNED ON per backend. # # Not a duplicate of what the two ambient registrations already run: they run whatever @@ -583,6 +978,7 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK primgen-query-reroute.log ENVIRONMENT "${MGL_ITEST_VULKAN_PRIMGEN_REROUTE_ENVIRONMENT}" ) @@ -618,6 +1014,7 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK point-size-demotion-gles.log ENVIRONMENT "${MGL_ITEST_GLES_POINT_SIZE_DEMOTION_ENVIRONMENT}" ) @@ -628,5 +1025,1474 @@ gtest_discover_tests(MobileGLIntegrationTest PROPERTIES LABELS integration-gpu TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK point-size-demotion-vulkan.log ENVIRONMENT "${MGL_ITEST_VULKAN_POINT_SIZE_DEMOTION_ENVIRONMENT}" ) + +# --- the third CI mode: MOBILEGL_PIPE_VERIFY ----------------------------------- +# +# ARCHITECTURE.md 13.2-(2) asks for a THIRD build mode next to pull and push: two state models in +# one address space, compared field by field at every verb boundary and again at every accessor +# read, 5-10x slower and never shipped. These entries are that mode's lane. They exist only when +# the library was configured with -DMOBILEGL_PIPE_VERIFY=ON, which is deliberate and is half of +# what makes the lane falsifiable: `ctest -L integration-verify --no-tests=error` in a build that +# forgot the option matches NO tests and fails, instead of reporting a green run of nothing. +# +# The other half is PipeVerifyArmingScenario.Armed, which asserts the library's own arming line - +# because MOBILEGL_PIPE_VERIFY=1 in the environment of a library that never compiled the +# comparator in is a silent no-op that looks exactly like a clean pass. +# +# Three things about the ENVIRONMENT properties below, each of which has already gone wrong once +# in this file: +# * every list APPENDS ${MGL_ITEST_COMMON_ENV} / ${MGL_ITEST_VULKAN_ENV}. A ctest ENVIRONMENT +# entry overrides the job environment for the names it lists, so an entry that named only its +# own knobs would lose the EGL vendor and Vulkan ICD pinning and run against whichever driver +# the loader found first. +# * the ambient Verify. entries name NEITHER MOBILEGL_PIPE_VERIFY_CORRUPT NOR +# MOBILEGL_PIPE_POISON_OMIT. That is what lets CI's two always-on negative-control steps +# export those knobs in the JOB environment and have them reach the test processes; a +# property entry of the same name would silently win and the controls would prove nothing. +# * MOBILEGL_LOG_FILE_PATH is per lane, and "per lane" is the exact limit of what it proves. It +# is the only channel a test process has for reading the library's own report (MG_Config is not +# reachable from this module), but the log is opened fopen(path, "w"), so every process in a +# lane TRUNCATES it: after an ambient lane of 400-odd entries the file holds the LAST process +# and nothing else. Reading it is therefore only sound in a filtered, one-entry lane - which is +# why the arming case has a lane and a log of its own below, and why neither this file nor CI +# may read the ambient logs as evidence about the entries that ran before the last one. The +# ambient path is kept for post-mortems (and to keep library chatter out of ctest's capture). +# --- G8: the handle ABA, three always-on arms ----------------------------------------- +# +# ALWAYS ON, in every build mode, which is deliberate: the Legacy arm asserts today's +# lifetimeId + weak_ptr guards and is meaningful in a pull build, and `ctest -R HandleRecycle` +# has to name the same entries whichever build directory it is pointed at (P2 brief G8 runs it +# against build-verify; D.3 part 1 runs it again as part of the interface-purity gate). +# +# One lane per arm, and each lane names MGITEST_HANDLE_ARM: the arm is not a property of the +# test body, it is the (MOBILEGL_PIPE_PUSH, MOBILEGL_PIPE_LEGACY_MEMOS, MOBILEGL_PIPE_HANDLE_ABA_CONTROL) +# triple the process was launched with, and the scenario skips in the ambient entries because +# none of that is configured there. +# +# Every list APPENDS the common/Vulkan environment for the reason spelled out above the verify +# block: a ctest ENVIRONMENT property REPLACES the job environment for the names it lists, so an +# entry naming only its own knobs would lose the EGL vendor and Vulkan ICD pinning. +# +# The AbaControl arm is DirectVulkan only. The knob defeats the object-identity half of +# DirectVulkan's vertex-input memo keys (VertexInputStateFactory::ComputeHash, its per-VAO memo +# table, and LookupVaoDrawMemo); it steers nothing on DirectGLES, and a lane that configured it +# there would be a permanent skip claiming to be a control. +# +# SO THE BUFFER ABA's CORRUPTION EVIDENCE IS MAGMA-ONLY, AND THAT IS RECORDED RATHER THAN LEFT +# AS AN ABSENCE (gates m10, closed here as a statement rather than as a lane). P3a re-keys the +# BUFFER on DirectGLES - resource_* now dispatches by handle - so the obvious next move is a +# DirectGLES `.AbaControl` lane over ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents. +# It is not a registration-only change and it is therefore not made here: the knob has exactly +# one consumer in the tree (MG_Backend/DirectVulkan/Renderer/MagmaPipeArms.h's +# MagmaPipeAbaControlDefeatsIdentity, which is what the MGL_ITEST_MAGMA_ABA probe above looks +# for), so a DirectGLES lane would run with the knob inert, fail to reproduce the corruption it +# asserts, and go RED in an always-on integration-gpu lane - the exact failure mode the header +# of HandleRecycleScenario.cpp records this file already having had once. +# +# What that costs, stated so the next reader does not have to re-derive it: on DirectGLES the +# buffer case's Handles arm proves the re-key does NOT alias, and nothing proves the reproducer +# could still see an aliasing that was reintroduced there. The positive control for that is a +# Features.PipeHandleAbaControl consumer over Espryt's resource slot table - one `if` in +# GetOrCreate / FindByHandle, the way MagmaPipeClaimSlotMemos is Magma's - and it belongs with +# whoever next touches that table, not with a lane registration. +# +# It gets TWO lanes, because there are two arms and the control has to reach the one P2 SHIPS. +# `AbaControl` is D18's lane verbatim (MOBILEGL_PIPE_PUSH=0, the pre-handle arm) and defeats the +# lifetime-id/address guards; `AbaControlHandles` runs the handle arm (MOBILEGL_PIPE_LEGACY_MEMOS=0, +# the default push mask) and defeats the object identity that SELECTS THE SLOT - the key the handle +# arm ships. With only the first lane the control says nothing at all about the re-key: the handle +# arm is not executed under MOBILEGL_PIPE_PUSH=0, so every guard it would have to defeat is in +# another branch. +# +# NEITHER lane exercises the GENERATION half of {slot, gen}, and no lane of this shape can. Magma's +# mint has no death notification and returns a slot only through its age sweep (256/1024 boundaries, +# MagmaPipeArms.h), so the five frame boundaries this scenario issues always hand the replacement a +# brand-new slot at Gen 1; a real reuse needs >= 1024 idle boundaries, which puts the two draws in +# different frames - where the only pixel-visible memo declines by design. The generation is covered +# by the unit suite MG_Test/Pipe/MagmaPipeIdentityTest.cpp instead, which drives a real +# retire -> reuse; MagmaPipeAbaControlDefeatsIdentity carries the measurement. +# +# The two PUSH-ONLY knobs of those arms are set only in a push build, and the lane NAMES are +# unaffected by that (an ENVIRONMENT property is not part of a test's name, so G2 still sees the +# same list in both builds). MOBILEGL_PIPE_LEGACY_MEMOS=0 says "never enter the legacy arm"; in a +# pull build the legacy arm is the ONLY arm and every Track-H subsystem bit is clear, which is +# precisely D14's startup Fatal{PipeLegacyMemosDisabled} condition - so a lane that set it there +# would abort the process before the scenario could report its skip. MOBILEGL_PIPE_HANDLE_ABA_CONTROL +# has no field to parse into in a pull build at all (Config.h declares it under #if MOBILEGL_PIPE_PUSH). +# A lane that needs an arm pins EVERY knob that selects it. A ctest ENVIRONMENT property overrides +# only the variables it names; the rest leak in from the job. The five-part gate's all-pull control +# arm runs `MOBILEGL_PIPE_PUSH=0 ctest -L integration-gpu` over the whole label, and without the +# explicit bitmask below that leaked PUSH=0 turned this lane's LEGACY_MEMOS=0 into D14's armless +# combination: the bring-up aborted, on purpose, and the lane went red for a reason that was never +# about handles. The mask is the PHASE default, not a hand-picked bit, so the lane keeps measuring +# the shape that ships: it was kMGPipeSubsystemsMigratedAtP2 (0x7f), 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=0x1fff") + set(MGL_ITEST_ABA_ARM_KNOBS "MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1") +else() + set(MGL_ITEST_HANDLES_ARM_KNOBS "") + set(MGL_ITEST_ABA_ARM_KNOBS "") +endif() + +mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS} + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=handles" ${MGL_ITEST_HANDLES_ARM_KNOBS} + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=legacy" "MOBILEGL_PIPE_PUSH=0" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba" "MOBILEGL_PIPE_PUSH=0" + ${MGL_ITEST_ABA_ARM_KNOBS} + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) +mgl_itest_join_environment(MGL_ITEST_VULKAN_HANDLE_ABA_HANDLES_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MGITEST_HANDLE_ARM=aba" + ${MGL_ITEST_HANDLES_ARM_KNOBS} ${MGL_ITEST_ABA_ARM_KNOBS} + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_VULKAN_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.HandleRecycle.Handles." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_HANDLES_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.Handles." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_HANDLES_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.HandleRecycle.Legacy." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_HANDLE_LEGACY_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.Legacy." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_LEGACY_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControl." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.HandleRecycle.AbaControlHandles." + TEST_FILTER "HandleRecycleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_HANDLE_ABA_HANDLES_ENVIRONMENT}" +) + +# --- G12: the CSO content-addressing negative control --------------------------------- +# +# PUSH BUILDS ONLY, and that is the honest scope rather than a convenience: the two counters the +# control reads (CallClass::RenderStateCsoMints / RenderStateCsoBinds) and the `cso[...]` bracket +# of the summary line are both `#if MOBILEGL_PIPE_PUSH` (PipeStats.h, PipeStats.cpp), so in a pull +# build there is no channel to read and an entry here would be a permanent skip. +# +# Each arm gets a LOG PATH OF ITS OWN. The library opens its log fopen(path, "w") - every process +# in a lane truncates it - and these two cases READ that log, so a shared path would have them +# reading a neighbour's bring-up under `ctest -j 4`. Same rule as the arming lane below. +# +# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets the +# workload be bracketed by two swaps and read back as a window covering exactly itself. +# +# Registered in EVERY build, including the pull build where there is no CSO at all, so that +# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a +# pull build MGITEST_PIPE_PUSH_BUILD is absent and both cases skip saying so. +# +# THE MASK IS THE PHASE DEFAULT AND THE CONTROL IS BIT 63, and the two must not be confused. The +# only thing these four lanes are an A/B about is `no CSO content addressing` (bit 63, +# kMGPipeBehaviourNoCsoContentAddressing), which is what separates the On lane from the Off lane. +# Every +# other bit is the build's shipping mask, so it moves with the phase: 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=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=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=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=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}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_CSO_OFF_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.On." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.CsoContentAddressing.Off." + TEST_FILTER "CsoContentAddressingScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_CSO_OFF_ENVIRONMENT}" +) + +# --- G12: the P3a subsystem A/B, and G10's map-persistent-roundtrips lanes ------------ +# +# THE MASKS ARE 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 +# against a backend nobody asked to change. +# +# Each reading entry gets a LOG PATH OF ITS OWN, and its ctest entry selects ONE case: the library +# opens the log fopen(path, "w") - every process in a lane truncates it - and these cases READ it. +# Two readers in one lane race under `ctest -j`, and the failure looks exactly like "the counter +# was never emitted". Same rule as the CSO lanes above and the verify arming lane below. +# +# MOBILEGL_PIPE_STATS_PERIOD=1 makes one summary line per eglSwapBuffers, which is what lets a +# workload be bracketed by two swaps and read back as a window covering exactly itself. +# +# Registered in EVERY build, including the pull build where none of the counters exists, so that +# `ctest -L integration-gpu` stays name-for-name identical between pull and push (gate G2). In a +# pull build MGITEST_PIPE_PUSH_BUILD is absent and every one of these cases skips saying so; a +# MOBILEGL_PIPE_PUSH value in the environment of a pull library steers nothing (Config.h declares +# the field inside the push guard), exactly as the HandleRecycle Legacy lanes already rely on. +mgl_itest_join_environment(MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=on" + "MOBILEGL_PIPE_PUSH=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 + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_RESOURCE_SUBSYSTEM_LANE=off" + "MOBILEGL_PIPE_PUSH=0x7f" "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/resource-subsystem-off-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemControl.On." + TEST_FILTER "ResourceSubsystemControlScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemControl.Off." + TEST_FILTER "ResourceSubsystemControlScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_RESOURCE_SUBSYSTEM_OFF_ENVIRONMENT}" +) + +# G10's two counting entries, one per scenario that has a claim about the counter: +# StorageBufferRegrow asserts N definitions cost N round trips (never one per draw), and +# LargeArenaAdoption asserts one adoption costs exactly one. Each names a single case in its +# TEST_FILTER, for the private-log reason above. +mgl_itest_join_environment(MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_MPR_LANE=storage-buffer-regrow" + "MOBILEGL_PIPE_PUSH=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=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}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.MapPersistentRoundtrips." + TEST_FILTER "StorageBufferRegrowScenario.NStorageDefinitionsCostNMapPersistentRoundtripsNotOnePerDraw" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_MPR_REGROW_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.MapPersistentRoundtrips." + TEST_FILTER "LargeArenaAdoptionScenario.AnAdoptionCostsExactlyOneMapPersistentRoundtrip" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_MPR_ARENA_ENVIRONMENT}" +) + +# LargeArenaAdoption's three behavioural cases under BOTH arms of the same A/B. This file is where +# an adopted store's whole life is exercised - the NULL-data definition that adopts it, an +# in-flight SubData, a readback and a GPU write - so if the handle path and the legacy +# BufferBackendOps path disagree about any of it, one of these two lanes goes red and names which. +# No log path: none of these three cases reads one, and giving them one would only add a file for +# four processes to truncate. The fourth case skips in both lanes for exactly that reason, saying +# so. +mgl_itest_join_environment(MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_PUSH=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" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemOn." + TEST_FILTER "LargeArenaAdoptionScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_ON_ENVIRONMENT}" +) +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.ResourceSubsystemOff." + TEST_FILTER "LargeArenaAdoptionScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_ARENA_SUBSYSTEM_OFF_ENVIRONMENT}" +) + +# --- 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. + set(MGL_ITEST_VERIFY_TIMEOUT 900) + + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # The arming assertion's own lane, one case per backend, with a log path nothing else writes to. + # + # PipeVerifyArmingScenario.Armed reads the library's log, and the log is a per-LANE resource: it + # is opened fopen(path, "w"), so every process in a lane truncates it. In the ambient Verify. + # lane that is 400-odd processes on one path, run `-j 4` in CI, and a whole-file read there + # races a neighbour's bring-up. Every other log-reading scenario in this file (UnlocatedIoBlocks, + # the primgen reroute, the point-size demotion) is registered exactly like this for the same + # reason. MGITEST_PIPE_ARMING_LANE is a harness marker - the library never reads it - and it is + # what makes the case skip in the ambient lane instead of racing there. + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_ARMING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" "MGITEST_PIPE_ARMING_LANE=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-arming-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_ARMING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" "MGITEST_PIPE_ARMING_LANE=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-arming-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # Negative control A (G4). MOBILEGL_PIPE_VERIFY_FATAL=0 so the process SURVIVES its own + # divergence and the case can read the report back out of the log; the CI step that exports + # the same corruption against the ambient lane, where FATAL keeps its default of 1, asserts + # the other half - that a divergence aborts and reds the entry. + mgl_itest_join_environment(MGL_ITEST_GLES_VERIFY_CORRUPT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters" "MOBILEGL_PIPE_VERIFY_FATAL=0" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-corrupt-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_VERIFY_CORRUPT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_VERIFY_CORRUPT=GetRenderStateParameters" "MOBILEGL_PIPE_VERIFY_FATAL=0" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-verify-corrupt-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # Negative control B (G5). The omission skips the STAMP of one field for one verb while still + # copying its value, which is indistinguishable from a fill row nobody wrote; the scenario + # forks, so the resulting std::abort() is a datum in waitpid() rather than a dead lane. + mgl_itest_join_environment(MGL_ITEST_GLES_POISON_OMIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-poison-omit-DirectGLES.log" + ${MGL_ITEST_COMMON_ENV}) + mgl_itest_join_environment(MGL_ITEST_VULKAN_POISON_OMIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectVulkan" "MOBILEGL_PIPE_VERIFY=1" + "MOBILEGL_PIPE_POISON_OMIT=GenerateMipmap:GetActiveTextureUnit" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/pipe-poison-omit-DirectVulkan.log" + ${MGL_ITEST_VULKAN_ENV}) + + # The whole suite again, per backend, with the comparator armed. Same scenarios, same + # assertions, but every backend read of frontend state is now checked against a snapshot taken + # from the live context at the verb boundary - which is what "the 742 integration entries + # prove push equals pull" means. Labelled integration-gpu as well so a verify build's + # `ctest -L integration-gpu` still describes the whole registration set. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Verify." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.Verify." + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_ENVIRONMENT}" + ) + + # The arming assertion, one entry per backend. This is the entry that fails a lane whose library + # never armed: it runs the same library and the same MOBILEGL_PIPE_VERIFY=1 as the ambient + # entries above, but unlike them it cannot be green against a library with no comparator + # compiled in. Its log is its own, so `-j 4` cannot make it flake. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.VerifyArming." + TEST_FILTER "PipeVerifyArmingScenario.Armed" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_ARMING_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.VerifyArming." + TEST_FILTER "PipeVerifyArmingScenario.Armed" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_ARMING_ENVIRONMENT}" + ) + + # One case each: the knobs are process-wide, so a corrupted or poisoned process cannot also be + # running the ambient assertions. These four entries are the ones that assert the RED - they + # pass when the comparator and the poison report, and go red when either stops. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.VerifyCorrupted." + TEST_FILTER "PipeVerifyArmingScenario.CorruptedFieldIsReported" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_VERIFY_CORRUPT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.VerifyCorrupted." + TEST_FILTER "PipeVerifyArmingScenario.CorruptedFieldIsReported" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_VERIFY_CORRUPT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.PoisonOmitted." + TEST_FILTER "PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_POISON_OMIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectVulkan.PoisonOmitted." + TEST_FILTER "PoisonOmissionScenario.OmittedFieldAbortsOnThatVerb" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-verify" + TIMEOUT ${MGL_ITEST_VERIFY_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_VULKAN_POISON_OMIT_ENVIRONMENT}" + ) +endif() + +# --- P5: the DirectGLES.Split. lanes and the persistent-map ARM lanes ------------------------ +# +# THE PRIVATE SECOND LABEL IS WHAT MAKES THE LANE FALSIFIABLE, and it is the verify block above +# copied verbatim for the same argument: `ctest -L integration-split --no-tests=error` in a build +# that forgot -DMOBILEGL_BUILD_DISAGGREGATED=ON matches NOTHING and FAILS, instead of reporting a +# green run of zero entries. Keeping `integration-gpu` as well is why "a split build's +# `ctest -L integration-gpu` still describes the whole registration set" stays true. +# +# REGISTERED INSIDE `if (MOBILEGL_BUILD_DISAGGREGATED)`, and that is what keeps gate G2 (pull and +# push name-for-name identical) untouched: neither of those two builds sets the option, so the +# Split family is absent from BOTH of them rather than present in one. The two SCENARIO FILES the +# lanes point at are added to the source list unconditionally, so their cases DO appear in the +# ambient DirectGLES./DirectVulkan. registrations of every build - they are ordinary GL scenarios +# and their monolith behaviour is the baseline the split arm is compared against. +# +# WHAT ARMS THE SPLIT LANES, AND WHY IT IS NOT A PROBE OVER SOURCE TEXT. +# +# Until a real client session exists, MOBILEGL_TRANSPORT=inproc is parsed (CONTRACT-P5 5) and then +# nothing consumes it, so a Split entry would go green having run monolith end to end - the exact +# failure the lane exists to prevent. The first version of this file answered "has the client +# landed" with a CMake conjunction over c0's stub files: the c1 symbols present AND no +# `Fatal.Unimplemented` surviving. REVIEW FINDING M-1 FALSIFIED IT BY PERFORMING IT - a +# `sed -i 's/Fatal{Unimplemented/Fatal{NotYetImplemented/'` over c0's six stubs, with every entry +# point still aborting and ImplementedVerbCount() still 0, armed all eleven lanes and EIGHT WENT +# GREEN. A single comment line carrying the marker did the opposite and would keep them dark +# forever (M-2), and two stub files outside the two probed directories allowed a partial arm (M-3). +# +# A statement about source text can always be falsified by editing source text, and the people +# most likely to edit it are the ones landing the packages the probe watches for. So the arming +# condition moved into the PROCESS: Harness/SplitRuntimePeek.cpp reads MG_Config::Transport, +# ClientSession::Active() and ImplementedVerbCount() - three values c0 shipped and documented, +# none of which a message edit can move - and every Split case skips, naming the first one that is +# not true. All this file has to decide now is whether that peek can be COMPILED, which is exactly +# "did this build compile MG_Remote", which is exactly the build option. +if (MOBILEGL_BUILD_DISAGGREGATED) + target_compile_definitions(MobileGLIntegrationTest PRIVATE -DMGITEST_SPLIT_RUNTIME_PEEK=1) + message(STATUS "Integration tests: the DirectGLES.Split. lanes arm from the RUNNING PROCESS - " + "MG_Config::Transport, ClientSession::Active() and ImplementedVerbCount() - and " + "skip naming the first of those that is not true") +endif() + +# Q-3, made a build fact rather than documentation. ConfigLoader logs the resolved transport at +# INFO, and that line is what run_trace_case.cmake reads back as proof a retrace really went +# split. At WARN or above it is compiled out and every split retrace reds for a reason that is not +# a defect; at DEBUG, ConfigLoader's unconditional env dump prints a confusable KEY=VALUE line in +# a PULL build too (review M-5, also fixed on the reading side). The integration lanes themselves +# no longer depend on the log level at all - they read the variable - so this is a WARNING. +if (MOBILEGL_BUILD_DISAGGREGATED AND DEFINED MOBILEGL_LOG_ACTIVE_LEVEL + AND NOT MOBILEGL_LOG_ACTIVE_LEVEL STREQUAL "MOBILEGL_LOG_LEVEL_INFO" + AND NOT MOBILEGL_LOG_ACTIVE_LEVEL STREQUAL "MOBILEGL_LOG_LEVEL_DEBUG") + message(WARNING "Integration tests: MOBILEGL_LOG_ACTIVE_LEVEL=${MOBILEGL_LOG_ACTIVE_LEVEL} in a " + "disaggregated build. ConfigLoader's transport line is MGLOG_I, so the " + "trace-replay split arm's transport-resolution assertion cannot see it and every " + "split retrace will red. Use MOBILEGL_LOG_LEVEL_INFO.") +endif() + +# Package b1's client-side persistent-map tracker, which is what Harness/PersistentMapPeek.cpp +# asks the membership question of (b1-v1.md 4.1 item 2). TWO halves, and both are needed: +# MOBILEGL_BUILD_DISAGGREGATED because a build that never compiled MG_Remote cannot LINK the call, +# and the content probe because b1 may not have landed it yet. __has_include in the peek would +# answer yes in a pull build - the header is in the source tree of every build - and turn a +# healthy pull build into a link error, which is why this decision is made here. +if (MOBILEGL_BUILD_DISAGGREGATED) + mgl_itest_probe_for_symbol(MGL_ITEST_PERSISTENT_MAP_TRACKER + "${MGL_ITEST_ROOT}/MobileGL/MG_Remote/Client" "IsLivePersistentMap") + if (MGL_ITEST_PERSISTENT_MAP_TRACKER) + message(STATUS "Integration tests: package b1's persistent-map tracker is present " + "(${MGL_ITEST_PERSISTENT_MAP_TRACKER}) - PersistentCoherentMapScenario's " + "membership assertion is live") + target_compile_definitions(MobileGLIntegrationTest PRIVATE -DMGITEST_PERSISTENT_MAP_TRACKER=1) + else() + message(STATUS "Integration tests: no source under MobileGL/MG_Remote/Client names " + "IsLivePersistentMap - package b1's tracker has not landed, so " + "PersistentCoherentMapScenario's membership assertion SKIPS") + endif() +endif() + +# ARCHITECTURE.md:543 - every new ctest ENVIRONMENT and add_trace_replay_test's SPLIT branch +# carries MOBILEGL_IPC_SERVER_PATH, because the dladdr fallback cannot find the server from a +# binary that links MobileGL_s statically. P5 is inproc-only and nothing reads the value yet; it +# is carried now so that an unparsed variable and a parsed-and-ignored one stop being +# indistinguishable the day P6 consumes it. A generator expression is deliberately NOT used here: +# a $ inside a gtest_discover_tests PROPERTIES value is written into the +# generated ctest include file verbatim and never expanded. +set(MGL_ITEST_SPLIT_SERVER_PATH "${CMAKE_BINARY_DIR}/libMobileGLServer.so") + +mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +# The two counting lanes, one per transport, and they are a PAIR: exit gate E3(c) asks that the +# split arm's `mpr` equal the monolith arm's, and one test process can only ever see its own. So +# both lanes run the same workload and assert the same constant, and the equality holds by +# construction with each half able to fail on its own. +# +# Each gets a LOG PATH OF ITS OWN and a TEST_FILTER that selects ONE case, for the reason +# PipeStatsWindow.h gives: the library opens its log fopen(path, "w"), so every process in a lane +# truncates it, and two readers in one lane race under `ctest -j`. RESOURCE_LOCK on top of that, +# for the reason the UnlocatedIoBlocks lane gives - measured on this tree, three runs of the full +# integration-gpu label at -j 8 produced 4, 0 and 2 spurious failures of the log-reading cases +# without it. +# +# The monolith lane declares NO arm: which arm AcquireMemoryRange takes for a sub-16-MiB +# PERSISTENT|WRITE|COHERENT map is a property of the driver and the build, so the case RECORDS it +# there and skips the assertion. Only the split lane - where R-6 pins the adopt tier at T2 - +# declares one. +# MOBILEGL_TRANSPORT=monolith IS NAMED HERE ON PURPOSE, and it is review finding N-6. This entry +# is the MONOLITH half of a pair; without the property a job-level or gate-level +# `MOBILEGL_TRANSPORT=inproc` export reaches it (observed: its own log then carried +# `Config: MOBILEGL_TRANSPORT=inproc`) and, in the inproc arm of the three-arm A/B, BOTH halves of +# the pair were inproc. Nothing failed - each half asserts the same constant - but the pair was +# not the pair its name describes. A ctest ENVIRONMENT property overrides the job environment, +# which is the one case where that is what you want. +mgl_itest_join_environment(MGL_ITEST_GLES_PMAP_ARM_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MGITEST_PMAP_LANE=1" "MOBILEGL_TRANSPORT=monolith" + "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/persistent-map-arm-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + +gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.PersistentMapArm." + TEST_FILTER "PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS integration-gpu + TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK persistent-map-arm-DirectGLES.log + ENVIRONMENT "${MGL_ITEST_GLES_PMAP_ARM_ENVIRONMENT}" +) + +if (MOBILEGL_BUILD_DISAGGREGATED) + # One block per scenario, following the DirectGLES.MapPersistentRoundtrips. precedent: a + # `:`-separated multi-pattern TEST_FILTER is not used anywhere in this file, so its escaping + # through gtest_discover_tests' flat PROPERTIES forwarding is unproven, and three blocks cost + # nothing. + # + # ClearThenReadPixelsScenario is target A of the reduced path and already existed; Triangle is + # target B and PersistentCoherentMap is target C, both new in P5. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST MGL_SPLIT_CLEAR_TESTS + TEST_FILTER "ClearThenReadPixelsScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST MGL_SPLIT_TRIANGLE_TESTS + TEST_FILTER "TriangleScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST MGL_SPLIT_PMAP_TESTS + TEST_FILTER "PersistentCoherentMapScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + + # ---- P5e fix1: THE PUSH-MONOLITH ARM, INSIDE THE GATED SET (ID-107) ---------------------- + # + # WHY A MONOLITH LANE CARRIES THE `integration-split` LABEL. That label does not mean "runs + # inproc" - it means "the set the disaggregation gate runs", which is exactly why it is + # registered inside this `if (MOBILEGL_BUILD_DISAGGREGATED)` and why the block above argues + # that a build without the option must match NOTHING under it. ID-81 gives the push build TWO + # server arms, selected at runtime by MG_Config::Transport, and until now only one of them had + # a gated entry: every Split block above exports MOBILEGL_TRANSPORT=inproc, and every + # split-only scenario skips itself the moment the runtime is not split (SplitRuntimeSkipReason + # in their SetUp), so the ambient monolith registration of those files runs zero cases. The + # arm the phone actually ships on was therefore ungated, and P5e landed a null dereference on + # it under 2250 green unit entries and 113 green split entries (ID-107: the game died in + # Lightmap. -> clearColorTexture -> glClear). A second arm needs a second lane, in the + # same set, or "the gate is green" keeps meaning "one of the two arms is green". + # + # MOBILEGL_TRANSPORT=monolith IS NAMED, for review finding N-6's reason exactly as the + # PersistentMapArm pair above names it: a ctest ENVIRONMENT property overrides the job + # environment, and without it a gate-level `MOBILEGL_TRANSPORT=inproc` export would quietly + # turn this block into a fourth copy of the split lane - the one failure mode a lane whose + # whole purpose is to be the OTHER arm cannot survive. + # + # The scenario is registered ambiently as well (its source is in the list above), so + # `DirectGLES.MonolithAttachmentClearScenario.*` and `DirectVulkan.…` still exist in + # integration-gpu; this block is the copy the gate can see, under a prefix that says which arm + # it pins. + mgl_itest_join_environment(MGL_ITEST_GLES_PUSH_MONOLITH_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=monolith" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.PushMonolithArm." + TEST_LIST MGL_PUSH_MONOLITH_ATTACHMENT_TESTS + TEST_FILTER "MonolithAttachmentClearScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_PUSH_MONOLITH_ENVIRONMENT}" + ) + + # ---- P5b d1: the indexed / instanced / multi-draw / indirect draw family under inproc ---- + # + # One block per scenario, the precedent above. IndexedDrawFamilyScenario is d1's own lane + # case: every case's picture depends on a d1 draw (an element-buffer glDrawElements, a + # client index array, a base vertex, the Minecraft trace's DrawElementsInstancedBaseVertex, + # the two multi-draws, an indirect draw), so a record that did not cross is a wrong picture + # and not a green. MultiDrawScenario, DrawParametersScenario and PrimitiveRestartScenario are + # the census's own DrawElements / MultiDrawElementsBaseVertex / DrawArraysInstancedBaseInstance + # / MultiDraw*Indirect* first-blocker scenarios, now at their pictures. The ONE case excluded + # by filter is MultiDrawScenario's ClientSideIndicesBatchMatchesUnrolledDraws: a multi-draw + # with client-side indices is refused BY NAME under split (Fatal{UnmigratedVerb, + # "MultiDrawElementsBaseVertex+CLIENT_INDICES"}, CONTRACT-P5B.md d1 - P8's HostResolve.cpp + # flattens it), which is its next first blocker and not a lane failure. It is listed in + # d1-v1.md, not deleted (G14). GuiBatchScenario (the census's other DrawElements scenario) is + # NOT registered: past its draws it stops at Fatal{UnmigratedVerb, "MemoryBarrier"}, which is + # i1's to flip; i1 registers it when that lands. + set(MGL_SPLIT_D1_SCENARIOS IndexedDrawFamilyScenario MultiDrawScenario DrawParametersScenario + PrimitiveRestartScenario) + set(MGL_SPLIT_D1_FILTER_IndexedDrawFamilyScenario "IndexedDrawFamilyScenario.*") + set(MGL_SPLIT_D1_FILTER_MultiDrawScenario + "MultiDrawScenario.*-MultiDrawScenario.ClientSideIndicesBatchMatchesUnrolledDraws") + set(MGL_SPLIT_D1_FILTER_DrawParametersScenario "DrawParametersScenario.*") + set(MGL_SPLIT_D1_FILTER_PrimitiveRestartScenario "PrimitiveRestartScenario.*") + foreach(mglItestD1Scenario IN LISTS MGL_SPLIT_D1_SCENARIOS) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST "MGL_SPLIT_D1_${mglItestD1Scenario}_TESTS" + TEST_FILTER "${MGL_SPLIT_D1_FILTER_${mglItestD1Scenario}}" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + endforeach() + + # ---- P5e mv: THE MULTI-DRAW TIERS, INSIDE THE GATED SET (ID-109's rule, one level down) -- + # + # WHY FIVE MORE LANES FOR ONE FILE. MultiDrawImpl asks the bound index buffer three questions + # and P5e (mv) moved all three onto the applier's record. Five of this driver's six tiers ask + # them from a DIFFERENT place in the batch - `RunIndirect` before any GL work, the rebased + # tier from the CPU shadow, `FlattenWithCompute` before PrepareForDraw has even run - and the + # resolved tier is ONE process-wide read of MOBILEGL_ESPRYT_MULTIDRAW_MODE (MultiDraw.cpp's + # ResolveTierOnce). The d1 block above leaves it at `auto`, and on every driver this gate has + # run against, `auto` lands on `ext` - a real multi-draw entry point that asks the index + # buffer nothing at all. Three of the five migrated sites therefore had no gated entry. + # + # That is ID-107's shape exactly one level down: code on a real arm, with no entry in the set + # the gate runs. The phase already paid 137 SEGFAULTs across 38 scenarios for the version of + # this that lived at the transport level, and the lesson ID-109 drew - "the gate runs the SET, + # so put the second arm in the set" - does not stop being true because the selector is an env + # knob rather than MG_Config::Transport. A manual sweep run once by one package decays to + # nothing by the next one. + # + # MOBILEGL_ESPRYT_MULTIDRAW_MODE IS A ctest ENVIRONMENT PROPERTY, not a job-level export, for + # review finding N-6's reason and the PushMonolithArm block's: the property overrides the job + # environment, so a gate that exported a mode of its own could not silently collapse these + # five lanes into five more copies of the `auto` one - the single failure mode a lane whose + # whole purpose is to pin a tier cannot survive. + # + # `ext` IS NOT REGISTERED HERE, deliberately: it is what `auto` already resolves to, so the + # d1 block above IS the ext lane and a sixth block would only run it twice. Every other tier + # was confirmed to resolve to ITSELF on this driver rather than falling back + # (`ResolveTier`'s "MOBILEGL_ESPRYT_MULTIDRAW_MODE= -> (driver supports: ext, + # basevertex, multiindirect, indirect, drawelements, compute (opt-in))"); a tier the driver + # refuses would resolve to `ext` and register an entry that quietly proves nothing, so if this + # list ever runs somewhere poorer, drop the refused tier rather than keeping a green that + # means "ext again". + # + # THE FILTER IS THE TIER-SENSITIVE WORKLOAD and nothing else, which is this file's own rule + # for an armed Split entry: MultiDrawScenario (its whole point is that a batch matches the + # unrolled draws, which is precisely what a wrong index buffer breaks quietly) and + # IndexedDrawFamilyScenario's three MultiDraw cases. The client-side-indices case keeps the + # d1 block's exclusion - it is refused by name under split before any tier is chosen. + # + # NOTE FOR THE STRICT-MARKER CENSUS (PREAMBLE-ADDENDUM-WAVE3 §4): these entries carry a THIRD + # name segment, so `strict_census2.sh` re-derives their gtest filter from the last two + # components and re-runs them WITHOUT the mode this block pins - which for these lanes is the + # whole point of the entry. They join Ct. / F1. / SmallRing. / PersistentMapArm. / NamedBlit. + # in that blind spot, so SplitLogPaths.cmake.in gives each of them a private log path: read + # the marker out of `build-split/MobileGL/MG_IntegrationTest/split-logs/.log`, which + # the LANE wrote under its own pin, instead of out of a re-run that lost it. + set(MGL_SPLIT_MULTIDRAW_TIERS basevertex multiindirect indirect drawelements compute) + set(MGL_SPLIT_MULTIDRAW_TIER_PREFIX_basevertex "BaseVertex") + set(MGL_SPLIT_MULTIDRAW_TIER_PREFIX_multiindirect "MultiIndirect") + set(MGL_SPLIT_MULTIDRAW_TIER_PREFIX_indirect "Indirect") + set(MGL_SPLIT_MULTIDRAW_TIER_PREFIX_drawelements "DrawElements") + set(MGL_SPLIT_MULTIDRAW_TIER_PREFIX_compute "Compute") + set(MGL_SPLIT_MULTIDRAW_TIER_FILTER + "MultiDrawScenario.*:IndexedDrawFamilyScenario.MultiDraw*-MultiDrawScenario.ClientSideIndicesBatchMatchesUnrolledDraws") + foreach(mglItestMultiDrawTier IN LISTS MGL_SPLIT_MULTIDRAW_TIERS) + mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_MULTIDRAW_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MOBILEGL_ESPRYT_MULTIDRAW_MODE=${mglItestMultiDrawTier}" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX + "DirectGLES.Split.MultiDrawTier${MGL_SPLIT_MULTIDRAW_TIER_PREFIX_${mglItestMultiDrawTier}}." + TEST_LIST "MGL_SPLIT_MULTIDRAW_${mglItestMultiDrawTier}_TESTS" + TEST_FILTER "${MGL_SPLIT_MULTIDRAW_TIER_FILTER}" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_MULTIDRAW_ENVIRONMENT}" + ) + endforeach() + + # ---- P5b package i1 (MG_Remote/CONTRACT-P5B.md §2 i1) -------------------------------- + # + # THE LANE CASE THAT PROVES THE RECORDS CROSSED, and it is a RESULT and not a probe (R-16: + # a probe may not arm against a stub). Each of these four scenarios asserts a value that + # only exists if the verb ran on the apply thread - the texel a compute shader stored + # through an image unit, the word glCopyImageSubData moved, the counter an atomic add left + # behind, the block a program pipeline rebound - so a sink that DECLINED, or a client that + # fell through to the driver, is red by the number it reads back and not by a tally. + # + # Before i1 every one of these aborted at the client's Fatal{UnmigratedVerb} on the first + # glBindImageTexture / glDispatchCompute / glCopyImageSubData / glShaderStorageBlockBinding; + # all four are green under inproc now (the census: 13 + 6 + 11 + 3 DirectGLES entries). + # RED ONCE BY DOING X: return false from ServerVerbSink::OnLaunchGrid before the + # gl.DispatchCompute call and UnboundImageDescriptor / AtomicCounter / ProgramPipeline all + # read back their initial values. + # + # THE FILTERS ARE NARROWED TO THE CASES THAT ACTUALLY EMIT, and that is ScenarioFixture's + # rule rather than a convenience: an armed `DirectGLES.Split.` case must move the client + # encoder's record ordinal (ScenarioFixture.h:86), because a lane whose workload produced no + # record is the "resolved the transport and then fell through to the driver" shape that + # every pixel assertion is blind to. ProgramPipelineScenario's other nine cases are pure + # name/state cases that draw nothing, so the whole-scenario filter would arm them and they + # would be red for having nothing to say. The two named here are the ones that were + # Fatal{UnmigratedVerb, "ShaderStorageBlockBinding"} on the c0b head. + # + # One block per scenario, following the three above: a `:`-separated multi-pattern + # TEST_FILTER is unproven through gtest_discover_tests' flat PROPERTIES forwarding. + set(MGL_SPLIT_I1_SCENARIOS + UnboundImageDescriptorScenario # bind_shader_image (72) + launch_grid (60), 13 cases + CopyImageLayeredScenario # resource_copy_region (53), 6 cases + AtomicCounterScenario # launch_grid (60) + memory_barrier (61), 3 cases + ProgramPipelineScenario) # set_storage_block_binding (75), the 2 storage-block cases + set(MGL_SPLIT_I1_FILTER_UnboundImageDescriptorScenario "UnboundImageDescriptorScenario.*") + set(MGL_SPLIT_I1_FILTER_CopyImageLayeredScenario "CopyImageLayeredScenario.*") + set(MGL_SPLIT_I1_FILTER_AtomicCounterScenario "AtomicCounterScenario.*") + set(MGL_SPLIT_I1_FILTER_ProgramPipelineScenario "ProgramPipelineScenario.*StorageBlock*") + foreach(mglItestI1Scenario IN LISTS MGL_SPLIT_I1_SCENARIOS) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST "MGL_SPLIT_I1_${mglItestI1Scenario}_TESTS" + TEST_FILTER "${MGL_SPLIT_I1_FILTER_${mglItestI1Scenario}}" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + endforeach() + + # ---- P5b t2: the transform-feedback spans, the XFB object bind, the patch parameter ------- + # + # MG_Remote/CONTRACT-P5B.md §2 t2. The three P5 lanes above are the REDUCED PATH's targets + # (a clear, a triangle, a persistent map); these two are the first lane entries whose GREEN + # DEPENDS ON A t2 RECORD HAVING CROSSED AND BEEN APPLIED BY THE BACKEND, which is the only + # statement a package flipping a verb can make that a unit case cannot: + # + # TessellationXfbCaptureScenario covers BOTH halves of t2 in one workload. Every case + # calls glPatchParameteri(GL_PATCH_VERTICES, n) and then draws GL_PATCHES into a + # transform-feedback capture, and asserts the CAPTURED BYTES. A patch_parameter (73) + # that did not reach the server tessellates at the previous patch size and the capture + # is the wrong length; a begin/end_stream_output (62/63) that did not reach it leaves + # the buffer holding the scenario's poison value, which is what those cases print. + # XfbCaptureBufferReuseScenario is the span family alone, across four buffer lifetimes + # (a buffer per span, one immutable-storage buffer, one respecified buffer, a + # respecification that changes the capture size). It is the case that would notice a + # span whose END crossed but whose BEGIN did not, because the second span's bytes + # would be the first span's. + # + # Both are DirectGLES only, deliberately, and the reason is measured rather than assumed: + # under Magma the capture is written into the server's resident slice and there is no route + # back, because MG_Backend/Init.cpp's ConsumedSubsystemsFor(DirectVulkan) withholds + # kMGPipeSubsystemResources, so BufferObject::SyncGpuWrites() emits no resource_readback. + # Every DirectVulkan XFB capture entry in the inproc census fails on exactly that (t2-v1.md + # §"the 51"), and it retires with P7 (Magma's resource family) / P9 (the readback carrier) - + # not here. A DirectVulkan arm of this lane would be red for a reason t2 cannot fix. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST MGL_SPLIT_T2_TESS_TESTS + TEST_FILTER "TessellationXfbCaptureScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST MGL_SPLIT_T2_XFB_TESTS + TEST_FILTER "XfbCaptureBufferReuseScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + + # ---- P5e package vi (MG_Remote/CONTRACT-P5E.md §5.1, ID-82 / ruling 2) -------------------- + # + # THE LANE ENTRY THAT DID NOT EXIST, and its absence was the brief's open item (a). Nothing + # else registered here draws from a vertex array in the APPLICATION's own memory: every + # other scenario binds GL_ARRAY_BUFFER first, so its glVertexAttribPointer argument is a + # byte offset. This is the one draw shape whose bytes have no wire form - the server uploads + # them by dereferencing a client pointer per draw - and it is therefore both the positive + # control for vi's decision to KEEP that arm alive under lockstep and the only entry that + # can reach the run-ahead refusal when ra publishes the caps bit. + # + # ---- P5e (ra2), ID-134: AND WITH THE CAPS BIT PUBLISHED IT IS A LANE OF ITS OWN ---------- + # + # "When ra publishes the caps bit" is now, and what these two entries reach is + # `Fatal{UnmigratedVerb, "DrawArrays+CLIENT_ARRAYS"}` / `"MultiDrawArrays+CLIENT_ARRAYS"` - + # ID-82's NAMED REFUSAL, working exactly as ruled. They cannot be green under run-ahead and + # they should not be: a lane that demanded them green would be demanding that the refusal + # not fire, which is the opposite of what vi registered them for. Staging the bytes is P8's. + # + # THE LABEL IS `integration-clientarrays-split` AND THE SPELLING IS THE POINT (ID-131, and + # gl's `integration-magma-split` above is the worked example): `ctest -L` takes a REGEX, so a + # label spelled `integration-split-clientarrays` is still MATCHED by `-L integration-split` + # and the entries would never have left the lane they were moved out of - a split that reads + # as done in every listing and is not. Counted rather than assumed: `ctest -N -L + # integration-split` is 179 after this change and was 181 before, and `-L integration-gpu` + # is 1357 against 1359. `integration-gpu` goes with it for the same reason `integration- + # magma-split` dropped it - a lane that must be green cannot contain an entry that must not. + # + # The two entries keep their private, distinct log paths (SplitLogPaths.cmake.in lists + # MGL_SPLIT_VI_CLIENT_ARRAY_TESTS by name), which is what lets the expected-red CI step + # assert the MARKER rather than merely tolerate a failure. + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST MGL_SPLIT_VI_CLIENT_ARRAY_TESTS + TEST_FILTER "ClientVertexArrayScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-clientarrays-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split." + TEST_LIST MGL_SPLIT_SYNC_TESTS + TEST_FILTER "SyncWireScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}" + ) + + # The split half of the counting pair. MGITEST_PERSISTENT_MAP_ARM=emulated is R-6: under split + # the adopt tier is pinned at T2, the resource owner declines every acquisition and the client + # pushes the mapping's dirty blocks - so pmap must be non-zero and mpr must be the monolith + # lane's number. pmap == 0 here means MGPipeApplyMapPersistent handed back a pointer, which is + # exit gate E3(d) failing, and under inproc that failure is invisible in pixels. + mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_PMAP_ARM_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MGITEST_PMAP_LANE=1" "MGITEST_PERSISTENT_MAP_ARM=emulated" + "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/persistent-map-arm-split-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.PersistentMapArm." + TEST_FILTER "PersistentCoherentMapScenario.TheMapLandsInTheArmItsLaneDeclares" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK persistent-map-arm-split-DirectGLES.log + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_PMAP_ARM_ENVIRONMENT}" + ) + + # --- EXIT GATE E3(e): the same checks with a ring small enough to force back-pressure ----- + # + # BRIEF 7 E3(e) - "以上五条再跑一遍 ring 小到足以至少发生一次背压等待的配置" - was missing from + # the first cut of this package and missing from its report (review finding M-7). It is a + # LANE, not a new scenario: the same three split scenarios with SEG_CMD and SEG_STAGE at their + # floor, so a workload that fits comfortably in the 8 MiB / 32 MiB defaults has to wrap and + # wait at least once. + # + # 1 MiB is ConfigLoader's minimum for both (Config.h / ConfigLoader.cpp:363-364, floors of 1 + # rather than 0 because a ring caps ONE record at half its size). That caps a record at 512 + # KiB, which every record on the reduced path is far under, so the ring is legal and small + # rather than unusable. + # + # WHAT THIS LANE DOES NOT YET ASSERT, stated rather than implied: that a back-pressure wait + # ACTUALLY happened. That needs a counter only package s1 can publish (a producer-parked or + # ring-full tally on RingControl); until it exists this lane proves the five checks survive a + # small ring, not that the small ring bit. The gap is named in t1-v1.md's debts rather than + # left for someone to discover from a green. + mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_SMALL_RING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MGITEST_SMALL_RING_LANE=1" "MOBILEGL_IPC_RING_MB=1" "MOBILEGL_IPC_STAGE_MB=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + + set(MGL_SPLIT_SMALL_RING_SCENARIOS ClearThenReadPixelsScenario TriangleScenario + PersistentCoherentMapScenario) + foreach(mglItestSmallRingScenario IN LISTS MGL_SPLIT_SMALL_RING_SCENARIOS) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.SmallRing." + TEST_LIST "MGL_SPLIT_SMALL_${mglItestSmallRingScenario}_TESTS" + TEST_FILTER "${mglItestSmallRingScenario}.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_SMALL_RING_ENVIRONMENT}" + ) + endforeach() + + # ---- P5e package pa: THE STRICT LANE GETS ENTRIES OF ITS OWN ------------------------------ + # + # pa retired the two per-draw program rows - Fatal{UnmigratedPipeInput, + # "GetProgramForDraw@DrawArrays"} and "...GetProgramForDispatch@DispatchCompute", 69 of the + # 109 entries the strict lane was red on. NOTHING GATED THEM. MOBILEGL_IPC_STRICT_ERRORS=1 is + # a knob the phase's gate driver sets by hand, no REGISTERED entry exports it, and package + # pg's declared red-once for this exact marker never landed - so the row could come back and + # every gate would stay green. That is ID-109's finding in miniature: the gap was never a + # missing test case, it was a missing gated ENTRY for a whole reading. + # + # WHICH SCENARIOS MAY JOIN is decided by the knob itself: under it every BARRIER-PULLED read + # is Fatal, and most split scenarios end in a readback, whose GetFramebufferBindingSlot@ReadPixels + # is an allowlisted row that only P7 retires. So a scenario is eligible exactly when it + # reaches PrepareForDraw / PrepareForCompute and then does NOT read pixels back. Two do, one + # per half of pa, and both were red with pa's own marker before it landed: + # * MultiDrawScenario.BaseVertexDrawsRejectMalformedArguments - the DRAW half; it prepares a + # draw and then asserts on GL errors rather than on pixels. + # * AtomicCounterScenario.* - the DISPATCH half, and all three cases were + # GetProgramForDispatch@DispatchCompute entries. + # + # LABELS IS integration-split ALONE, deliberately, and it is not the hole ID-109 names. That + # hole was a runtime ARM with no entry anywhere; here the monolith arm of these same scenarios + # is registered under DirectGLES. already and this knob is a no-op on it - a monolith client + # fills and stamps all 63 fields at every verb, so no read is unfresh and nothing can trip. + # An entry that cannot fail for the reason it exists is worse than none (R-16), so this lane + # names the arm it is for in its own prefix. The F1. lane sets the same precedent. + mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_STRICT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MOBILEGL_IPC_STRICT_ERRORS=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.StrictProgramArm." + TEST_LIST MGL_SPLIT_PA_STRICT_DRAW_TESTS + TEST_FILTER "MultiDrawScenario.BaseVertexDrawsRejectMalformedArguments" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_STRICT_ENVIRONMENT}" + ) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.StrictProgramArm." + TEST_LIST MGL_SPLIT_PA_STRICT_DISPATCH_TESTS + TEST_FILTER "AtomicCounterScenario.*" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_STRICT_ENVIRONMENT}" + ) + # Apply per-entry paths after GoogleTest discovery has populated the TEST_LISTs. + configure_file(Harness/SplitLogPaths.cmake.in SplitLogPaths.cmake @ONLY) + set_property(DIRECTORY APPEND PROPERTY TEST_INCLUDE_FILES + "${CMAKE_CURRENT_BINARY_DIR}/SplitLogPaths.cmake") + # Python is mandatory for this split integration gate (also used by the CI controls). + # Do not silently omit the ownership check when the interpreter is unavailable. + find_package(Python3 REQUIRED COMPONENTS Interpreter) + add_test(NAME SplitLogPaths.PrivateAndDistinct + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_SOURCE_DIR}/Harness/split_log_paths.py" + check "${CMAKE_CTEST_COMMAND}" "${CMAKE_BINARY_DIR}") + set_tests_properties(SplitLogPaths.PrivateAndDistinct PROPERTIES LABELS "integration-split") +endif() + +# f1: split-only source keeps the monolith registry unchanged. +if (MOBILEGL_BUILD_DISAGGREGATED) + target_sources(MobileGLIntegrationTest PRIVATE Scenarios/F1WireScenario.cpp) + foreach(f1Slot ClearBufferfi ClearBufferfv ClearBufferiv ClearBufferuiv ClearNamedFramebufferfi ClearNamedFramebufferfv ClearNamedFramebufferiv ClearNamedFramebufferuiv CopyTexImage2D CopyTexSubImage2D GenerateMipmap GenerateMipmapPackedFloat GenerateMipmapDepth) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.F1." + TEST_FILTER "F1WireScenario.${f1Slot}Pixels" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p5b-f1-${f1Slot}.log" + ) + endforeach() +endif() + +# P5c ct (MG_Remote/CONTRACT-P5C.md §5): the two control records, split-only source for the +# same reason as f1's - the scenario reads the SERVER sink's tallies, which a monolith build +# has no symbol for. One block per case with a LOG PATH OF ITS OWN, the F1 pattern: +# SplitLogPaths.PrivateAndDistinct fails a lane whose cases share one file, and the death +# case's child truncates whatever path it inherits. +if (MOBILEGL_BUILD_DISAGGREGATED) + target_sources(MobileGLIntegrationTest PRIVATE Scenarios/CtWireScenario.cpp) + foreach(ctCase ApplierResetCrossesAtThePrimedEdgeInSerialOrder + TextureDeathCrossesAndTheRecycledSlotAnswersTheNewObject + FramebufferDeathCrossesAndTheRecycledSlotAnswersTheNewObject + TheDirectApplierResetCallOnTheGLThreadIsRoleViolation) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.Ct." + TEST_FILTER "CtWireScenario.${ctCase}" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_ENVIRONMENT}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p5c-ct-${ctCase}.log" + ) + endforeach() +endif() + +# P5b measured named-blit blockers, exercising both backend bound-form lowerings. +# +# P5e (gl), CONTRACT-P5E §7: THE TWO BACKENDS GET DIFFERENT LABELS, because under strict they are +# answering different questions. +# +# Both DirectVulkan entries abort on GetFramebufferBindingSlot@Clear (VulkanRenderer.cpp:7648). +# That field's row retires in P5e ON ESPRYT and in P7 ON MAGMA, and FieldOwnership.def carries ONE +# retiring-phase string for both - so the derived allowlist cannot admit the pair without admitting +# it for Espryt too, where it would forgive exactly the regression fb's handle arm exists to +# prevent (an Espryt Clear reading the frontend's binding slot again would become a warning line). +# The alternative to a label is therefore not "admit one row"; it is "stop the Espryt lane from +# being able to see that row at all", which is a real loss of gate strength. +# +# So the lane is SPLIT rather than the allowlist WIDENED: `integration-split` stays a statement +# about Espryt and goes hard green, and the Magma entries get the expected-red step the contract +# already promises them for the backend it keeps in lockstep all phase (ID-90). +if (MOBILEGL_BUILD_DISAGGREGATED) + foreach(namedBlitBackend DirectGLES DirectVulkan) + mgl_itest_join_environment(MGL_ITEST_NAMED_BLIT_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=${namedBlitBackend}" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_ITEST_REQUIRE_GPU=1" "MGITEST_SPLIT_LANE=1" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + # THE LABEL IS `integration-magma-split` AND NOT `integration-split-magma`, and that is not + # a preference: `ctest -L` takes a REGEX, not an exact name, so `-L integration-split` + # MATCHES a label spelled `integration-split-magma` as a substring. The obvious name would + # have left both entries in the Espryt lane while every listing showed them relabelled - a + # split that looks done and is not. Measured: with that spelling + # `ctest -N -L integration-split` still listed both DirectVulkan entries. + if (namedBlitBackend STREQUAL "DirectVulkan") + set(MGL_ITEST_NAMED_BLIT_LABELS "integration-magma-split") + else() + set(MGL_ITEST_NAMED_BLIT_LABELS "integration-split") + endif() + foreach(namedBlitCase NamedBlitPreservesBindingsAndRestoresNextVerbsPixels NamedBlitDefaultEndpointPixels) + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "${namedBlitBackend}.Split.NamedBlit." + TEST_FILTER "F1WireScenario.${namedBlitCase}" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "${MGL_ITEST_NAMED_BLIT_LABELS}" + TIMEOUT ${MGL_ITEST_TIMEOUT} + ENVIRONMENT "${MGL_ITEST_NAMED_BLIT_ENVIRONMENT}\;MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p5b-${namedBlitBackend}-${namedBlitCase}.log") + endforeach() + endforeach() +endif() + +# ===================================================================================== +# P5e (gl), ID-115 / ID-119: THE STRICT LANE'S POSITIVE CONTROL, AND THE ONE COUNTING ENTRY +# ===================================================================================== +# +# ONE ENTRY, TWO JOBS, AND BOTH NEED THE SAME THING - a drawing split scenario that can read its +# own PipeStats window. +# +# ID-115: prove strict was ARMED. Of the seven entries that passed the strict lane before this +# phase, three were monolith transport (where nothing stamps a verb boundary and the whole +# mechanism is structurally unreachable), two self-skipped, one was a death test and one was a +# Python check - so "116/116" and "strict never ran on anything that draws" were the same +# observation. `vbs` on the summary line is the arming proof and this entry is where it is read. +# +# ID-119: the `rsp` pin. The old wording ("rsp = 0 on unbarriered records") was true of every +# possible implementation, because CountBarrierPull's unbarriered arm is [[noreturn]] and runs +# BEFORE the counter. The checkable statement is the relation to `draws`, which is what the +# device measured (rsp ~= the draw count under inproc, 0 under monolith). +# +# IT IS ITS OWN ENTRY WITH ITS OWN LOG PATH, NOT A WHOLE-LANE ENV FLIP. The library opens +# MOBILEGL_LOG_FILE_PATH with "w", so every process in a lane TRUNCATES it; two entries sharing +# one path under `ctest -j` race into an empty read that looks exactly like "the counter was +# never emitted" (Harness/PipeStatsWindow.h:19-24 has the long version). RESOURCE_LOCK is the +# same belt as the PersistentMapArm entry's. +# +# BLOCK PLACED AT THE END OF THE FILE ON PURPOSE (wave-3 integration): `pa`'s +# DirectGLES.Split.StrictProgramArm. block and `mv`'s multi-draw tiers are added inside the big +# disaggregated section above, so keeping this one textually apart lets all three merge cleanly. +if (MOBILEGL_BUILD_DISAGGREGATED) + mgl_itest_join_environment(MGL_ITEST_GLES_SPLIT_STRICT_ARMING_ENVIRONMENT + "MOBILEGL_BACKEND_TYPE=DirectGLES" "MOBILEGL_TRANSPORT=inproc" + "MOBILEGL_IPC_SERVER_PATH=${MGL_ITEST_SPLIT_SERVER_PATH}" "MGITEST_SPLIT_LANE=1" + "MGITEST_STRICT_ARMING_LANE=1" + "MOBILEGL_PIPE_STATS=1" "MOBILEGL_PIPE_STATS_PERIOD=1" + "MOBILEGL_LOG_FILE_PATH=${CMAKE_CURRENT_BINARY_DIR}/p5e-strict-arming-DirectGLES.log" + ${MGL_ITEST_CAPABILITY_ENV} ${MGL_ITEST_COMMON_ENV}) + + gtest_discover_tests(MobileGLIntegrationTest + TEST_PREFIX "DirectGLES.Split.StrictArming." + TEST_FILTER "TriangleScenario.TheServerStampedAVerbBoundaryOnThisDrawingFrame" + DISCOVERY_TIMEOUT 30 + PROPERTIES + LABELS "integration-gpu\;integration-split" + TIMEOUT ${MGL_ITEST_TIMEOUT} + RESOURCE_LOCK p5e-strict-arming-DirectGLES.log + ENVIRONMENT "${MGL_ITEST_GLES_SPLIT_STRICT_ARMING_ENVIRONMENT}" + ) +endif() diff --git a/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp new file mode 100644 index 000000000..03e3e95e3 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp @@ -0,0 +1,42 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "BackendCapsPeek.h" + +#if !defined(__ANDROID__) +#include + +namespace MobileGL::MG_Backend { + // Declared in MG_Backend/BackendObjects.h, which also pulls in both backends' headers + // and, through them, their loaders; the reference alone is all that is needed here. + extern UniquePtr& pActiveBackendObject; +} // namespace MobileGL::MG_Backend +#endif + +namespace MGITest { + + bool PeekComputeWorkGroupCaps(int outCount[3], int outSize[3]) { +#if defined(__ANDROID__) + (void)outCount; + (void)outSize; + return false; +#else + const auto& backend = MobileGL::MG_Backend::pActiveBackendObject; + if (!backend) { + return false; + } + const MobileGL::MG_Backend::DynamicBackendParameters& caps = backend->GetDynamicParameters(); + for (int axis = 0; axis < 3; ++axis) { + outCount[axis] = caps.MaxComputeWorkGroupCount[axis]; + outSize[axis] = caps.MaxComputeWorkGroupSize[axis]; + } + return true; +#endif + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h new file mode 100644 index 000000000..773b5dba4 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h @@ -0,0 +1,29 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/BackendCapsPeek.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The one place this module looks past the GL API into the active backend's caps block. +// +// It exists for exactly one assertion: that the six per-axis compute limits the MGPipe +// caps block carries (DynamicBackendParameters::MaxComputeWorkGroupCount/Size, plan B +// section 4.4.1) are the same numbers glGetIntegeri_v answers today, since P0.5 retires +// the getter in favour of the caps. A separate translation unit, because the scenario +// sources include the GL headers with prototypes and MobileGL's umbrella header is not +// meant to meet them in one file. + +#pragma once + +namespace MGITest { + + // Copies the active backend's MaxComputeWorkGroupCount / MaxComputeWorkGroupSize into the + // two arrays and returns true. Returns false, touching nothing, where the caps block is + // out of reach: on Android this module links the SHIPPING libMobileGL.so, built + // -fvisibility=hidden, so no internal symbol resolves; on desktop it links MobileGL_s and + // the read is direct. + bool PeekComputeWorkGroupCaps(int outCount[3], int outSize[3]); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.cpp new file mode 100644 index 000000000..23c8a4ce0 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.cpp @@ -0,0 +1,71 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.cpp +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "P4aFinalFixPeek.h" + +#if !defined(__ANDROID__) +#include +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#define MGITEST_P4A_FINALFIX_PEEK_LIVE 1 +#endif +#endif + +namespace MGITest { + +#if defined(MGITEST_P4A_FINALFIX_PEEK_LIVE) + namespace { + namespace MGP = MobileGL::MG_Pipe; + } // namespace + + bool PeekPipeTextureResourceRecord(unsigned glTextureName, PipeTextureResourceRecordPeek* out) { + if (out == nullptr) return false; + const MGP::MGPipeApplierState& applier = MGP::MGPipeApplier(); + // Slot 0 is the reserved null slot; the walk is the same shape PipeApplyPeek.cpp's + // params reading takes. A GL name is never an identity on the wire, which is exactly + // why it is the right key for a harness that starts from the application's view. + for (MobileGL::SizeT slot = 1; slot < applier.TextureResources.size(); ++slot) { + const MGP::MGPipeResourceRecord& record = applier.TextureResources[slot]; + if (!record.Live) continue; + if (record.Desc.GlNameForDiag != static_cast(glTextureName)) continue; + out->Slot = static_cast(slot); + out->Gen = static_cast(record.Gen); + out->Serial = static_cast(record.Serial); + out->BindMask = static_cast(record.Desc.BindMask); + out->ImageBindableHint = static_cast(record.Desc.ImageBindableHint); + out->Levels = static_cast(record.Desc.Levels); + out->PendingUploads = static_cast(record.PendingUploads.size()); + return true; + } + return false; + } + + bool PeekPipeStatsTextureRemintPulls(unsigned long long* out) { + if (out == nullptr) return false; + namespace Stats = MobileGL::MG_Util::PipeStats; + if (!Stats::Enabled()) Stats::SetEnabledForTesting(true); + *out = static_cast(Stats::TotalCalls(Stats::CallClass::TextureRemintPulls)); + return true; + } + + bool PeekPipeStatsTextureUploadEmissions(unsigned long long* out) { + if (out == nullptr) return false; + namespace Stats = MobileGL::MG_Util::PipeStats; + if (!Stats::Enabled()) Stats::SetEnabledForTesting(true); + *out = static_cast(Stats::TotalCalls(Stats::CallClass::TextureUploadEmissions)); + return true; + } +#else + bool PeekPipeTextureResourceRecord(unsigned, PipeTextureResourceRecordPeek*) { return false; } + bool PeekPipeStatsTextureRemintPulls(unsigned long long*) { return false; } + bool PeekPipeStatsTextureUploadEmissions(unsigned long long*) { return false; } +#endif + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.h b/MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.h new file mode 100644 index 000000000..65646d3cd --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.h @@ -0,0 +1,41 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/P4aFinalFixPeek.h +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The white-box readings P4aFinalFixScenario.cpp takes, in a translation unit of their own for +// P4aSeamPeek.h's reason: a scenario TU includes the GL prototype headers and cannot include +// MG_Pipe/PipeApply.h or the Espryt managers beside them, and PipeApplyPeek.cpp is the gates +// package's file. Every entry point answers false where the reading cannot be taken (a pull +// build, Android, or an applier that holds no record for the name), and a false teaches the +// caller nothing - the case declines that half by name and keeps its public-GL verdict. +#pragma once + +namespace MGITest { + + // The applier's resource record for a texture, found by its GL name (GlNameForDiag - a + // diagnostics-only field, which is exactly what a test harness is). + struct PipeTextureResourceRecordPeek { + unsigned Slot; + unsigned Gen; + unsigned long long Serial; + unsigned BindMask; + unsigned ImageBindableHint; + unsigned Levels; + unsigned PendingUploads; + }; + bool PeekPipeTextureResourceRecord(unsigned glTextureName, PipeTextureResourceRecordPeek* out); + + // The process-wide texture-remint pull count (PipeStats "tex-remint-pulls", `trp=` on the + // summary line; ROADMAP open question 2). Arms the PipeStats counters for this process on + // the first call, which is what lets a case read the number without a stats-enabled lane. + bool PeekPipeStatsTextureRemintPulls(unsigned long long* out); + // Espryt's count of texture uploads it actually issued (PipeStats "tex-upload-emissions"): + // what tells a CONSUMED pending upload apart from a DROPPED one, since the record's set is + // empty either way. Arms the counters the same way. + bool PeekPipeStatsTextureUploadEmissions(unsigned long long* out); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.cpp new file mode 100644 index 000000000..88315ca74 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.cpp @@ -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 +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#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(applier.ShaderImageStart); + out->Count = static_cast(applier.ShaderImageCount); + out->Serial = static_cast(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(bound->GetBackendSamplerId()); + } + + const MGP::MGPipeHandle cso = applier.BoundSamplerStates[unit]; + out->CsoHandleSlot = static_cast(cso.Slot); + out->CsoHandleGen = static_cast(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((*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(glSamplerName)); + if (object) { + if (auto* const slot = MGB::SamplerImpl::g_backendSamplerObjects.Find(object.get()); slot && *slot) { + out->IdentityTwinSamplerId = static_cast((*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 diff --git a/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.h b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.h new file mode 100644 index 000000000..9c5e5b1f3 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/P4aSeamPeek.h @@ -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 diff --git a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp new file mode 100644 index 000000000..6c957e262 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.cpp @@ -0,0 +1,91 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.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 "PersistentMapPeek.h" + +#if !defined(__ANDROID__) +#include +#include +#define MGITEST_PERSISTENT_MAP_PEEK_LIVE 1 +#endif + +// MGITEST_PERSISTENT_MAP_TRACKER is defined by MG_IntegrationTest/CMakeLists.txt, and only when +// BOTH halves are true: the build compiled MG_Remote (so the symbol can link) AND some source +// under MG_Remote/Client names IsLivePersistentMap (so package b1 landed it). __has_include is +// NOT enough on its own - the header exists in the source tree of every build, including the pull +// build that never compiles MG_Remote, so keying on it would turn a healthy pull build into a +// link error. +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) && defined(MGITEST_PERSISTENT_MAP_TRACKER) +#include +#define MGITEST_PERSISTENT_MAP_TRACKER_LIVE 1 +#endif + +namespace MGITest { + + bool PersistentMapPeekAvailable() { +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) + return true; +#else + return false; +#endif + } + + bool PersistentMapTrackerAvailable() { +#if defined(MGITEST_PERSISTENT_MAP_TRACKER_LIVE) + return true; +#else + return false; +#endif + } + +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) + namespace { + // The frontend object behind a GL buffer name, or null. GetBufferObject mints on demand + // for a name that was generated and never bound, which is harmless here: a scenario only + // ever asks about a buffer it has already defined and mapped, and a null store answers + // "not adopted", which is the same answer an un-mapped buffer would give. + MobileGL::MG_State::GLState::BufferObject* FrontendBuffer(unsigned int bufferName) { + if (bufferName == 0) return nullptr; + auto& context = MobileGL::MG_State::pGLContext; + if (!context) return nullptr; + const auto& buffer = context->GetBufferObject(static_cast(bufferName)); + return buffer.get(); + } + } // namespace +#endif + + bool PeekBufferIsAdoptedPersistentMap(unsigned int bufferName, bool* outAdopted) { +#if defined(MGITEST_PERSISTENT_MAP_PEEK_LIVE) + if (outAdopted == nullptr) return false; + MobileGL::MG_State::GLState::BufferObject* buffer = FrontendBuffer(bufferName); + if (buffer == nullptr) return false; + *outAdopted = static_cast(buffer->IsBackendPersistentMapped()); + return true; +#else + (void)bufferName; + (void)outAdopted; + return false; +#endif + } + + bool PeekBufferIsLivePersistentMap(unsigned int bufferName, bool* outLive) { +#if defined(MGITEST_PERSISTENT_MAP_TRACKER_LIVE) + if (outLive == nullptr) return false; + MobileGL::MG_State::GLState::BufferObject* buffer = FrontendBuffer(bufferName); + if (buffer == nullptr) return false; + *outLive = static_cast( + MobileGL::MG_Remote::Client::PersistentMapTracker::IsLivePersistentMap(*buffer)); + return true; +#else + (void)bufferName; + (void)outLive; + return false; +#endif + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h new file mode 100644 index 000000000..374401a06 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.h @@ -0,0 +1,61 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PersistentMapPeek.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 +// +// WHICH ARM A PERSISTENT|WRITE|COHERENT MAP LANDED IN, read from a scenario. +// +// It exists for exit gate E3's first assertion, and package b1 (b1-v1.md 4.1) names the +// spelling: `IsBackendPersistentMapped()` must be FALSE, i.e. the store was NOT adopted and the +// CPU shadow is still the source of truth, i.e. the emulated arm. The question has no answer in +// the GL API at all - both arms map, both arms take the application's writes, both arms draw the +// same pixels - so a scenario with no peek is a scenario that silently tests whichever arm the +// driver and the build happened to choose. `MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION` does not +// separate them either: it is read only inside TryAdoptLargeStorage, and a scenario-sized buffer +// never reaches the 16 MiB threshold that calls it. +// +// A separate translation unit for BackendCapsPeek.h's reason, verbatim: the scenario sources +// include the GL headers with prototypes and MobileGL's umbrella header is not meant to meet them +// in one file. +// +// Every entry point returns FALSE, touching nothing, where the state is out of reach - on Android +// this module links the shipping libMobileGL.so built -fvisibility=hidden, so no internal symbol +// resolves, and the tracker half additionally needs a build that compiled MG_Remote/Client. A +// caller that gets false must SKIP rather than pass: "could not look" is not "it was emulated". + +#pragma once + +namespace MGITest { + + // True when the peek can answer at all in this build. A scenario asks this first so that its + // skip message can name WHY it could not look. + bool PersistentMapPeekAvailable(); + + // BufferObject::IsBackendPersistentMapped() for the buffer with this GL name. + // + // returns false -> could not look (no peek in this build, no current context, or no such + // buffer). *outAdopted is untouched. + // returns true -> *outAdopted is true on the ADOPTED arm (the resource owner minted + // host-visible coherent storage and the shadow was released) and false on + // the EMULATED arm (the owner declined; the shadow is the truth and the + // client has to push blocks). R-6 pins the split arm at emulated. + bool PeekBufferIsAdoptedPersistentMap(unsigned int bufferName, bool* outAdopted); + + // True when this build compiled package b1's client-side persistent-map tracker, so the + // membership predicate below means something. CMake answers it, by probing MG_Remote/Client + // for the symbol: a build that never compiled MG_Remote cannot link the call, so the decision + // has to be made before the compiler sees it rather than by __has_include. + bool PersistentMapTrackerAvailable(); + + // MG_Remote::Client::PersistentMapTracker::IsLivePersistentMap() for this buffer - b1-v1.md + // 4.1 item 2. The membership set is meant to be exactly the early-out chain of + // SyncPersistentMappedRange; asking it here is what makes a drift between the two fail in a + // named test rather than silently stop the push. + // + // Same contract as above: false means "could not look". + bool PeekBufferIsLivePersistentMap(unsigned int bufferName, bool* outLive); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.cpp new file mode 100644 index 000000000..d2d09e687 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.cpp @@ -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 +#if MOBILEGL_PIPE_PUSH +#include +#include +#include +#include +#include +#include +#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(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(MobileGL::MG_Util::ConvertTextureSwizzleParamToGLEnum( + static_cast(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(glTextureName)) continue; + out->Slot = static_cast(slot); + out->Gen = static_cast(record.Gen); + out->ParamsSerial = static_cast(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(bindingQuery), &previousBinding); + MGB::g_GLESFuncs.glBindTexture(static_cast(glTarget), backendId); + + out->BackendTextureId = static_cast(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(glTarget), + kSwizzlePnames[channel], &value); + out->Swizzle[channel] = static_cast(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(glTarget), + GL_DEPTH_STENCIL_TEXTURE_MODE, &mode); + out->DepthStencilModeIsReadable = MGB::g_GLESFuncs.glGetError() == GL_NO_ERROR; + out->DepthStencilMode = static_cast(mode); + + MGB::g_GLESFuncs.glBindTexture(static_cast(glTarget), + static_cast(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(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 diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.h b/MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.h new file mode 100644 index 000000000..233e09821 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PipeApplyPeek.h @@ -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 diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.cpp b/MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.cpp new file mode 100644 index 000000000..3da241fd1 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.cpp @@ -0,0 +1,82 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +#include "PipeSlotPeek.h" + +#if !defined(__ANDROID__) +#include +#if MOBILEGL_PIPE_PUSH +#include +#define MGITEST_PIPE_SLOT_PEEK_LIVE 1 +#endif +#endif + +namespace MGITest { + +#if defined(MGITEST_PIPE_SLOT_PEEK_LIVE) + namespace { + // One arm per member, and NO `default:` on purpose: adding a PipeSlotKind without + // deciding which MGPipeKind it names is a compiler warning here (-Wswitch) rather than + // a row that silently counts VertexElementsCso and reports "did not leak" about a kind + // it never looked at. The trailing return is the unreachable one the compiler needs. + MobileGL::MG_Pipe::MGPipeKind Translate(PipeSlotKind kind) { + switch (kind) { + case PipeSlotKind::Buffer: return MobileGL::MG_Pipe::MGPipeKind::Buffer; + case PipeSlotKind::VertexElementsCso: + return MobileGL::MG_Pipe::MGPipeKind::VertexElementsCso; + case PipeSlotKind::Texture: return MobileGL::MG_Pipe::MGPipeKind::Texture; + case PipeSlotKind::Renderbuffer: return MobileGL::MG_Pipe::MGPipeKind::Renderbuffer; + case PipeSlotKind::Framebuffer: return MobileGL::MG_Pipe::MGPipeKind::Framebuffer; + case PipeSlotKind::SamplerCso: return MobileGL::MG_Pipe::MGPipeKind::SamplerCso; + case PipeSlotKind::SamplerViewCso: + return MobileGL::MG_Pipe::MGPipeKind::SamplerViewCso; + case PipeSlotKind::ShaderCso: return MobileGL::MG_Pipe::MGPipeKind::ShaderCso; + } + return MobileGL::MG_Pipe::MGPipeKind::None; + } + } // namespace + + bool PeekPipeSlotLiveCount(PipeSlotKind kind, unsigned* outLive) { + if (outLive == nullptr) return false; + *outLive = static_cast(MobileGL::MG_Pipe::MGPipeSlots().LiveCount(Translate(kind))); + return true; + } + + bool PeekPipeSlotHighWater(PipeSlotKind kind, unsigned* outHighWater) { + if (outHighWater == nullptr) return false; + // The ORDINARY space only, for every kind including ShaderCso (contract-v2.md 4.3). + *outHighWater = static_cast(MobileGL::MG_Pipe::MGPipeSlots().HighWater(Translate(kind))); + return true; + } + + bool PeekPipeCompositeSlotLiveCount(unsigned* outLive) { + if (outLive == nullptr) return false; + *outLive = static_cast(MobileGL::MG_Pipe::MGPipeSlots().CompositeLiveCount()); + return true; + } + + bool PeekPipeCompositeSlotHighWater(unsigned* outHighWater) { + if (outHighWater == nullptr) return false; + *outHighWater = static_cast(MobileGL::MG_Pipe::MGPipeSlots().CompositeHighWater()); + return true; + } + + bool PeekPipeCompositeSlotBandBase(unsigned* outBandBase) { + if (outBandBase == nullptr) return false; + *outBandBase = static_cast(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 diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.h b/MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.h new file mode 100644 index 000000000..530d36a39 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.h @@ -0,0 +1,101 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeSlotPeek.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// The CLIENT slot allocator's occupancy, read from a scenario. +// +// It exists for one assertion, P3a's C-1: a frontend object that dies must return its +// MGPipeHandle slot WHATEVER BACKEND IS RUNNING. That question has no answer in the GL API - +// the leak it rules out is entirely inside the library, and it is invisible in pixels, in GL +// names and in `glGetError` - so the only honest observable is the allocator's own live count +// and high-water mark. Reading them is what makes the case fail on the backend it actually +// failed on (DirectVulkan, which installs no StateObjectDeathOps) rather than only on the one +// where a backend-owned free happened to exist. +// +// A separate translation unit for BackendCapsPeek.h's reason, verbatim: the scenario sources +// include the GL headers with prototypes and MobileGL's umbrella header is not meant to meet +// them in one file. + +#pragma once + +namespace MGITest { + + // Which client-side object kind to ask about. Mirrors MG_Pipe::MGPipeKind for exactly the + // kinds a scenario has a reason to count, so that the enum does not travel through this + // header and the GL headers together. + enum class PipeSlotKind { + Buffer, + VertexElementsCso, + // P4a's six (G8b). Every one of them is a kind the CLIENT mints and the client alone + // frees (BRIEF-P4A.md D-I1: one death helper per kind, called from the frontend + // object's own destructor, whatever backend is running), so every one of them can leak + // the P3a C-1 way - and the leak is invisible in pixels, in GL names and in + // glGetError, exactly as the VertexElementsCso one was. + Texture, + Renderbuffer, + // Framebuffer has a HANDLE but no wire lifetime (D-I2): no create_*, no destroy row in + // the catalogue, and its death helper does the notice and the free and emits nothing. + // That makes the allocator the ONLY observable of its lifetime, so this row matters + // more here than the others rather than less. + Framebuffer, + SamplerCso, + SamplerViewCso, + // ShaderCso covers BOTH the ordinary program slots and the program-pipeline COMPOSITES + // minted out of the reserved high band (MGPipeHandles.h:86-107, D-H7). One kind, because + // that is what the allocator has: the band is a second dense table inside the same kind + // and LiveCount counts both. + // + // THE TWO SPACES' HIGH-WATER MARKS ARE NOT ONE NUMBER, and the correction matters here + // more than anywhere else. c0b split them (contract-v2.md 4.3): HighWater(ShaderCso) is + // now the ORDINARY space only and the band's own mark is CompositeHighWater(), because + // a merged mark is pinned at ~983k from the first composite mint onward and every "the + // high-water mark did not move over N churn rounds" assertion about ordinary programs + // would be vacuously true for the rest of the process. The composite's leak case is a + // separate CASE and reads the BAND'S OWN counters below (PeekPipeCompositeSlot*) - a + // composite's slot has TWO independent release paths (the pipeline cache's LRU eviction + // and the composite ProgramObject's destructor), and a slot that never comes back to + // the band moves neither of the ordinary numbers. + ShaderCso, + }; + + // Live slots of this kind right now, and one past the highest slot ever handed out. + // Both return false, touching nothing, where the allocator is out of reach: in a PULL + // build there is no allocator at all (it is `#if MOBILEGL_PIPE_PUSH`), and on Android this + // module links the shipping libMobileGL.so built -fvisibility=hidden, so no internal symbol + // resolves. A caller that gets false must SKIP rather than pass - "could not look" is not + // "did not leak". + bool PeekPipeSlotLiveCount(PipeSlotKind kind, unsigned* outLive); + bool PeekPipeSlotHighWater(PipeSlotKind kind, unsigned* outHighWater); + + // The ShaderCso COMPOSITE BAND's own three numbers, the seventh..ninth members + // contract-v2.md 4.3 asks this header for. There is no `kind` argument because the band is + // ShaderCso's alone - AllocateComposite is the one door into it and no other kind has one. + // All three return false on the same terms as the two above, and a caller that gets false + // must SKIP. + // + // PeekPipeCompositeSlotLiveCount = MGPipeSlotAllocator::CompositeLiveCount(), the band's + // share of LiveCount(ShaderCso). + // PeekPipeCompositeSlotHighWater = CompositeHighWater() VERBATIM, i.e. one past the + // highest band slot ever handed out. It is an ABSOLUTE + // slot number and therefore starts at the band's base, + // not at zero - "no composite was ever minted" reads as + // `high water == band base`, which is what the third + // member is for. It is not returned base-relative + // because a peek whose name says HighWater and whose + // value is a delta is exactly the kind of quietly + // redefined counter this member exists to correct. + // PeekPipeCompositeSlotBandBase = kMGPipeShaderCsoCompositeSlotBase, the floor the + // other two are read against. A constant, but it + // reaches a scenario only through this header: the + // MG_Pipe headers and the GL headers are not meant to + // meet in one translation unit, which is why this + // harness exists at all. + bool PeekPipeCompositeSlotLiveCount(unsigned* outLive); + bool PeekPipeCompositeSlotHighWater(unsigned* outHighWater); + bool PeekPipeCompositeSlotBandBase(unsigned* outBandBase); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h new file mode 100644 index 000000000..02e86746b --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h @@ -0,0 +1,113 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/PipeStatsWindow.h +// Copyright (c) 2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Reading ONE PipeStats summary window out of the library's own log, for the scenarios whose +// claim is about a counter rather than about pixels. +// +// WHY THROUGH A LOG FILE AT ALL. MG_Util::PipeStats is internal to the library and this module +// cannot link against it (ScenarioFixture.h has the long version: on Android this binary links +// the SHIPPING libMobileGL.so, built -fvisibility=hidden). The library's `MGPipe stats:` line is +// the only channel, so a lane that wants to read a counter sets MOBILEGL_PIPE_STATS=1, +// MOBILEGL_PIPE_STATS_PERIOD=1 - one line per eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of +// its OWN. +// +// THE LOG PATH HAS TO BE PRIVATE TO ONE CTEST ENTRY, and that is not a style rule: the library +// opens it fopen(path, "w"), so every process launched in a lane TRUNCATES it. Two entries of one +// lane reading the same path race under `ctest -j`, and the shape of the failure is an empty read +// that looks exactly like "the counter was never emitted". So a case that reads a window gets a +// ctest entry whose TEST_FILTER selects that case alone, with a log path nothing else writes - +// the rule PipeVerifyArmingScenario and CsoContentAddressingScenario already follow. +// +// THE WINDOW IS "SINCE THE PREVIOUS LINE" (PipeStats::FormatWindowLine), so the caller closes the +// setup window with a swap, runs the workload, swaps again, and reads the LAST line - which then +// covers the workload and nothing else. + +#pragma once + +#include +#include +#include +#include +#include + +namespace MGITest::PipeStatsWindow { + + // The lane's private log path, or empty when the lane configured none. + inline std::string LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::string(path) : std::string(); + } + + inline std::string ReadWholeFile(const std::string& path) { + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + // The last summary line in the log, verbatim. `found` is false when the library never emitted + // one, which is a different failure from "the counter read zero" and has to be reported as + // one: it means the stats channel never reached the process, not that the workload did + // nothing. + struct Window { + bool found = false; + std::string line; + }; + + inline Window Last(const std::string& log) { + Window window; + const std::string marker = "MGPipe stats:"; + const std::size_t at = log.rfind(marker); + if (at == std::string::npos) return window; + const std::size_t end = log.find('\n', at); + window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at); + window.found = true; + return window; + } + + inline Window LastFromLaneLog() { return Last(ReadWholeFile(LibraryLogPath())); } + + // One counter out of that line, by its short name ("mpr", "draws", "csom"), or -1 when the + // line does not carry it. The search includes the SEPARATOR before the name and the `=` after + // it, so "draws" cannot match "draws/f=" and "mpr" cannot match a longer name ending in it - + // a substring match here would read a neighbouring counter's value and report it as this + // one's, which is the one way a counter assertion can be wrong without ever failing. + inline long long CounterOrAbsent(const Window& window, const char* shortName) { + if (!window.found) return -1; + // A counter is preceded either by a space (` mpr=`, ` draws=`) or by its bracket's + // opening (`cso[csom=`, `bytes/f[stage-buffer=`); nothing in the line is preceded by + // anything else. + for (const char* prefix : {" ", "["}) { + const std::string key = std::string(prefix) + shortName + "="; + const std::size_t at = window.line.find(key); + if (at == std::string::npos) continue; + return std::strtoll(window.line.c_str() + at + key.size(), nullptr, 10); + } + return -1; + } + + // The same lookup for a counter that is printed as a FIXED-POINT PER-FRAME FIGURE rather + // than as an integer, which is every member of the bytes/f[...] bracket: FormatWindowLine + // divides each byte class by the window's frame count and prints two decimals whenever the + // window contains a Present. `pmap` is one of those, so CounterOrAbsent's strtoll reads + // "0.37" as 0 and an assertion that a push HAPPENED silently becomes an assertion that it + // pushed at least one whole byte per frame - the one way this counter can be wrong without + // ever failing. Returns -1.0 when the line does not carry the name; every real value of a + // byte class is >= 0, so the sentinel cannot collide with one. + inline double CounterAsDoubleOrAbsent(const Window& window, const char* shortName) { + if (!window.found) return -1.0; + for (const char* prefix : {" ", "["}) { + const std::string key = std::string(prefix) + shortName + "="; + const std::size_t at = window.line.find(key); + if (at == std::string::npos) continue; + return std::strtod(window.line.c_str() + at + key.size(), nullptr); + } + return -1.0; + } + +} // namespace MGITest::PipeStatsWindow diff --git a/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h b/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h index 25a71d1dd..e3055c040 100644 --- a/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h +++ b/MobileGL/MG_IntegrationTest/Harness/ScenarioFixture.h @@ -28,6 +28,7 @@ #include #include "HeadlessGL.h" +#include "SplitLane.h" namespace MGITest { @@ -66,6 +67,33 @@ namespace MGITest { class ScenarioTest : public ::testing::Test { protected: + // THE BEHAVIOURAL HALF OF THE SPLIT LANE'S CLAIM, and it is in the DESTRUCTOR rather than + // in TearDown() on purpose: gtest calls only the MOST DERIVED TearDown, and every scenario + // that overrides it would have to remember to chain here. The fixture destructor always + // runs, and it runs before the test result is finalized, so ADD_FAILURE() is recorded. + // + // What it asserts: a case that ran in an ARMED split lane must have moved the client + // encoder's record ordinal. Everything else in the lane - the pixels, the readbacks, the + // arm assertion - is equally true of a monolith run of the same workload; this is the one + // statement that is only true if records crossed the ring. An emit table that resolves the + // transport and then falls through to the driver passes every other assertion in the file + // and fails exactly here. + ~ScenarioTest() override { + if (!m_splitAssertionsArmed) return; + if (IsSkipped() || HasFailure()) return; + const SplitRuntimeState after = PeekSplitRuntime(); + if (after.emitSeq <= m_emitSeqAtSetUp) { + ADD_FAILURE() << "this case ran in an armed DirectGLES.Split. lane and the client " + "encoder's record ordinal did not move: EmitSeq was " + << m_emitSeqAtSetUp << " at SetUp and is " << after.emitSeq + << " now. The workload drew, cleared and read pixels, so records were " + "due - a sequence that did not advance means the emit table " + "resolved the transport and then did not put anything on the wire, " + "which every other assertion in this lane is blind to because a " + "monolith run of the same workload produces the same pixels."; + } + } + void SetUp() override { m_ready = false; HeadlessGL& gl = HeadlessGL::Get(); @@ -95,6 +123,36 @@ namespace MGITest { FAIL() << "MOBILEGL_ITEST_REQUIRE_HARDWARE_GPU is set but the context landed on a software " << "rasterizer: " << gl.RendererString(); } + // P5's DirectGLES.Split. lanes, in ONE place rather than in each scenario they point + // at - the Split family also points at ClearThenReadPixelsScenario, which is target A + // of the reduced path and predates P5, and any later Split lane gets the same + // guarantee without anyone having to remember it. + // + // THE ARMING QUESTION IS ASKED OF THE PROCESS, not of the source tree. Until a real + // client session exists, MOBILEGL_TRANSPORT=inproc is parsed and then nothing consumes + // it, so every case in the lane would go GREEN against the monolith path under a name + // that says it tested the split one. The first version of this guard answered the + // question with a CMake grep over c0's stub files, and review finding M-1 falsified it + // by renaming one string: eleven lanes armed and eight went green. Harness/ + // SplitRuntimePeek.h now answers it from MG_Config::Transport, ClientSession::Active() + // and ImplementedVerbCount(), none of which a message edit can move. Registrations are + // never deleted (gate G14); they skip, naming exactly which fact is not true. + if (SplitLane::IsSplitLane()) { + if (const std::string splitSkip = SplitLane::SkipReasonForSplitOnlyAssertions(); + !splitSkip.empty()) { + GTEST_SKIP() << splitSkip; + } + // Armed. Take the wire's baseline, so the destructor can require that this case + // actually PUT SOMETHING THROUGH IT (review finding N-5: ten of the eleven Split + // entries had no runtime evidence of anything, and their green meant only "the + // same GL workload passed"). + const SplitRuntimeState state = PeekSplitRuntime(); + m_splitAssertionsArmed = true; + m_emitSeqAtSetUp = state.emitSeq; + RecordProperty("split_transport", state.transportName); + RecordProperty("split_implemented_verbs", static_cast(state.implementedVerbs)); + RecordProperty("split_emit_seq_at_setup", static_cast(state.emitSeq)); + } // A scenario starts from a clean slate but shares the context (and so // the renderer's memos) with every other scenario in this process - // which is exactly the situation both shipped bugs needed. @@ -122,6 +180,8 @@ namespace MGITest { } bool m_ready = false; + bool m_splitAssertionsArmed = false; + unsigned long long m_emitSeqAtSetUp = 0; }; } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitLane.h b/MobileGL/MG_IntegrationTest/Harness/SplitLane.h new file mode 100644 index 000000000..35b7df602 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/SplitLane.h @@ -0,0 +1,84 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/SplitLane.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 harness markers the `DirectGLES.Split.` ctest entries set. +// +// WHAT IS AND IS NOT DECIDED HERE. These markers say what the LANE asked for. Whether the lane +// GOT it is a different question and it is answered by Harness/SplitRuntimePeek.h, out of the +// running process - see the long argument in that header. The split of responsibility matters: +// an environment variable is a request, and this package's first version treated a request (plus +// a grep over source text) as evidence that the request had been honoured. Review finding M-1 +// falsified that by renaming one string in six files, which armed eleven lanes and turned eight +// of them green against the monolith path. +// +// MGITEST_SPLIT_LANE=1 +// Set by the DirectGLES.Split.* entries and by nothing else. It is how a case in ONE binary, +// registered many times over, knows which registration it is running under. It is NOT +// evidence of anything about the transport. +// +// MGITEST_PERSISTENT_MAP_ARM=adopted|emulated +// The arm the LANE declares. AcquireMemoryRange adopts a PERSISTENT|WRITE map that is not +// FLUSH_EXPLICIT whenever the resource owner mints one (BufferObject.cpp:645-661), and +// declines to the shadow otherwise - two completely different code paths, chosen by the +// driver and the build rather than by the test, and MOBILEGL_DISABLE_LARGE_BUFFER_ADOPTION +// does NOT separate them (it guards TryAdoptLargeStorage's 16 MiB path, which a +// scenario-sized buffer never reaches at all). So the lane states which arm it expects and +// PersistentCoherentMapScenario asserts it landed there, through +// Harness/PersistentMapPeek.h's read of IsBackendPersistentMapped(). R-6 pins the split lane +// at T2 = declined = emulated. +// +// MGITEST_PMAP_LANE=1 +// The one counting entry per transport that reads the library's summary line back. It has a +// MOBILEGL_LOG_FILE_PATH of its own and a RESOURCE_LOCK on it. +// +// MGITEST_SMALL_RING_LANE=1 +// Exit gate E3(e)'s lane: the same split scenarios with MOBILEGL_IPC_RING_MB and +// MOBILEGL_IPC_STAGE_MB at their floor, so that the ring is small enough to make at least one +// back-pressure wait happen. A case uses it only to say so in its recorded properties; the +// ring sizes themselves reach the library through MOBILEGL_IPC_*. + +#pragma once + +#include +#include + +#include "SplitRuntimePeek.h" + +namespace MGITest::SplitLane { + + inline std::string MarkerValue(const char* name) { + const char* value = std::getenv(name); + return (value != nullptr) ? std::string(value) : std::string(); + } + + inline bool MarkerIsOne(const char* name) { return MarkerValue(name) == "1"; } + + // True in the DirectGLES.Split.* entries only. + inline bool IsSplitLane() { return MarkerIsOne("MGITEST_SPLIT_LANE"); } + + // True in exit gate E3(e)'s small-ring lane. + inline bool IsSmallRingLane() { return MarkerIsOne("MGITEST_SMALL_RING_LANE"); } + + // Empty when this case may assert; otherwise the reason to GTEST_SKIP() with. The reason is + // spelled out rather than summarised because a skip line is the only thing anyone reads when + // they ask "did the split lane actually run" - and because the previous version of this + // message named the wrong missing thing (review finding N-1): it said MG_Remote/Client did + // not exist, on a tree where it existed and compiled and every entry point aborted. + inline std::string SkipReasonForSplitOnlyAssertions() { + if (!IsSplitLane()) { + return "not the split lane (MGITEST_SPLIT_LANE is unset): this case's split-only " + "assertions are about a live MG_Remote client session and say nothing in a " + "monolith process"; + } + return SplitRuntimeSkipReason(); + } + + // "adopted", "emulated", or empty when the lane declared nothing. + inline std::string DeclaredPersistentMapArm() { return MarkerValue("MGITEST_PERSISTENT_MAP_ARM"); } + +} // namespace MGITest::SplitLane diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitLogPaths.cmake.in b/MobileGL/MG_IntegrationTest/Harness/SplitLogPaths.cmake.in new file mode 100644 index 000000000..60f465abc --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/SplitLogPaths.cmake.in @@ -0,0 +1,52 @@ +# Included by CTest after all GoogleTest discovery files (ID-53). +# CTest appends ENVIRONMENT here; keep all previously discovered lane settings. +file(MAKE_DIRECTORY "@CMAKE_CURRENT_BINARY_DIR@/split-logs") +# P5b t2's two lanes ride the same rule: one private log path per entry, or +# SplitLogPaths.PrivateAndDistinct is red for them (ID-53). +# P5e vi's client-array lane joins the same list for the same reason: its two entries are the +# only ones that can reach Fatal{UnmigratedVerb, "DrawArrays+CLIENT_ARRAYS"} once ra publishes +# the caps bit, and a shared log file is a marker nobody can attribute. +# P5e pa's strict lane joins it for the same reason as vi's: its four entries exist to CARRY a +# Fatal marker (the two program rows), and a shared log file is a marker nobody can attribute. +foreach(entry IN LISTS MGL_SPLIT_CLEAR_TESTS MGL_SPLIT_TRIANGLE_TESTS MGL_SPLIT_PMAP_TESTS + MGL_SPLIT_T2_TESS_TESTS MGL_SPLIT_T2_XFB_TESTS MGL_SPLIT_SYNC_TESTS + MGL_SPLIT_VI_CLIENT_ARRAY_TESTS + MGL_SPLIT_PA_STRICT_DRAW_TESTS MGL_SPLIT_PA_STRICT_DISPATCH_TESTS) + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MOBILEGL_LOG_FILE_PATH=@CMAKE_CURRENT_BINARY_DIR@/split-logs/${entry}.log") +endforeach() +foreach(scenario @MGL_SPLIT_SMALL_RING_SCENARIOS@) + foreach(entry IN LISTS MGL_SPLIT_SMALL_${scenario}_TESTS) + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MOBILEGL_LOG_FILE_PATH=@CMAKE_CURRENT_BINARY_DIR@/split-logs/${entry}.log") + endforeach() +endforeach() +# P5b d1's split entries: the same private, distinct log per entry. +foreach(scenario @MGL_SPLIT_D1_SCENARIOS@) + foreach(entry IN LISTS MGL_SPLIT_D1_${scenario}_TESTS) + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MOBILEGL_LOG_FILE_PATH=@CMAKE_CURRENT_BINARY_DIR@/split-logs/${entry}.log") + endforeach() +endforeach() + +# P5e mv's five tier-pinned lanes, the same rule for the same reason - and here it also repairs +# the addendum §4 blind spot for them: these entries carry a third name segment, so the strict +# census cannot re-derive their MOBILEGL_ESPRYT_MULTIDRAW_MODE, but a private log per entry means +# its marker can be read where the LANE wrote it instead of from a re-run that lost the pin. +foreach(tier @MGL_SPLIT_MULTIDRAW_TIERS@) + foreach(entry IN LISTS MGL_SPLIT_MULTIDRAW_${tier}_TESTS) + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MOBILEGL_LOG_FILE_PATH=@CMAKE_CURRENT_BINARY_DIR@/split-logs/${entry}.log") + endforeach() +endforeach() + +# P5b package i1's four scenarios, the same shape as the small-ring loop above: every +# DirectGLES.Split. entry owns exactly one absolute log path and shares it with nobody, which +# is what SplitLogPaths.PrivateAndDistinct checks over the discovered set. +foreach(scenario @MGL_SPLIT_I1_SCENARIOS@) + foreach(entry IN LISTS MGL_SPLIT_I1_${scenario}_TESTS) + set_tests_properties("${entry}" PROPERTIES ENVIRONMENT + "MOBILEGL_LOG_FILE_PATH=@CMAKE_CURRENT_BINARY_DIR@/split-logs/${entry}.log") + endforeach() +endforeach() +# PersistentMapArm retains its existing private path and RESOURCE_LOCK: b1 reads it. diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp new file mode 100644 index 000000000..81a3b9922 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.cpp @@ -0,0 +1,109 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.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 "SplitRuntimePeek.h" + +// MGITEST_SPLIT_RUNTIME_PEEK is defined by MG_IntegrationTest/CMakeLists.txt under +// MOBILEGL_BUILD_DISAGGREGATED and nowhere else. NO SOURCE PROBE decides it: the three symbols +// below are c0's, they exist in every disaggregated build from the contract commit onward, and +// their VALUES are what answer the question. That is the whole of the fix for review findings +// M-1, M-2 and M-3 - there is no longer a string for anyone to rename, comment out, or land +// outside a probed directory. +#if defined(MGITEST_SPLIT_RUNTIME_PEEK) && !defined(__ANDROID__) +#include + +#include +#include +#include +#include +#include +#define MGITEST_SPLIT_RUNTIME_PEEK_LIVE 1 +#endif + +namespace MGITest { + void DelaySplitRetirementForTesting(bool enabled) { +#if defined(MGITEST_SPLIT_RUNTIME_PEEK_LIVE) + MobileGL::MG_Remote::Server::ServerLoopInstance().SetBeforeRetireHookForTesting( + enabled ? +[] { std::this_thread::sleep_for(std::chrono::milliseconds(30)); } : nullptr); +#else + (void)enabled; +#endif + } + + SplitRuntimeState PeekSplitRuntime() { + SplitRuntimeState state; +#if defined(MGITEST_SPLIT_RUNTIME_PEEK_LIVE) + using MobileGL::MG_Config::TransportMode; + state.peekAvailable = true; + state.totalVerbSlots = MobileGL::MG_Remote::Client::kRemoteEmitSlotCount; + switch (MobileGL::MG_Config::Transport) { + case TransportMode::Monolith: state.transportName = "monolith"; break; + case TransportMode::InProcess: state.transportName = "inproc"; break; + default: state.transportName = "non-monolith"; break; + } + state.transportResolved = MobileGL::MG_Config::Transport != TransportMode::Monolith; + + // Active() is c0's one deliberately non-aborting accessor: "does a session exist" has a + // legitimate no. Everything below it is only reached through a live session, so nothing + // here can trip one of c0's Fatal stubs. + MobileGL::MG_Remote::Client::ClientSession* session = + MobileGL::MG_Remote::Client::ClientSession::Active(); + state.sessionActive = session != nullptr; + state.implementedVerbs = MobileGL::MG_Remote::Client::ImplementedVerbCount(); + if (session != nullptr) { + // Encoder() returns the member; EmitSeq() returns m_emitSeq. Neither is a stub, and + // neither emits anything - this is a read. + const MobileGL::MG_Remote::Wire::PipeWireEncoder& encoder = session->Encoder(); + state.emitSeq = encoder.EmitSeq(); + // The producer's ledger. Every one of these is a plain member read on the encoder + // or on the RingProducer it holds; none of them emits, publishes or waits, so a + // case may read them between two GL calls without changing what the next record is. + state.maxRecordBytes = encoder.MaxRecordBytesSeen(); + state.maxRecordBytesCap = encoder.MaxRecordBytesCap(); + state.cmdWraps = encoder.CmdWraps(); + state.cmdWrapPads = encoder.CmdWrapPads(); + state.cmdBytesWritten = encoder.CmdBytesWritten(); + state.stageReclaimWaits = encoder.StageReclaimWaits(); + } +#endif + return state; + } + + std::string SplitRuntimeSkipReason() { + const SplitRuntimeState state = PeekSplitRuntime(); + if (!state.peekAvailable) { + return "this build did not compile MG_Remote (no -DMOBILEGL_BUILD_DISAGGREGATED=ON), or " + "this is the Android binary, which links the shipping libMobileGL.so built " + "-fvisibility=hidden and can reach no internal symbol. MOBILEGL_TRANSPORT is " + "ACCEPTED AND SILENTLY IGNORED in such a build (CONTRACT-P5 5), so a green here " + "would be a monolith run under a name that says split"; + } + if (!state.transportResolved) { + return "MG_Config::Transport resolved to '" + state.transportName + + "', not to a split transport. The variable is read from the process, not from a " + "log line - a DEBUG-level pull build prints the same KEY=VALUE string out of " + "ConfigLoader's env dump (review M-5). Check MOBILEGL_TRANSPORT reached this " + "process"; + } + if (!state.sessionActive) { + return "MG_Remote::Client::ClientSession::Active() is null: no client session exists in " + "this process. c0 shipped Start() as a Fatal stub and Active() as a deliberate " + "null, so this is what 'packages s1 (construction and handshake) and c1 have not " + "landed' looks like from inside a running test. The entry stays registered (gate " + "G14) and skips rather than passing against the monolith path"; + } + if (state.implementedVerbs == 0) { + return "MG_Remote::Client::ImplementedVerbCount() is 0 of " + + std::to_string(state.totalVerbSlots) + + ": the emit table has no real emitter, so every verb this scenario issues would " + "take the Fatal{UnmigratedVerb} arm or fall through. Package c1 owns it"; + } + return {}; + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h new file mode 100644 index 000000000..f4e7ad884 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.h @@ -0,0 +1,113 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/SplitRuntimePeek.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 +// +// WHETHER THIS PROCESS IS REALLY RUNNING SPLIT, asked of the process rather than of the source +// tree. This is what arms every `DirectGLES.Split.` entry. +// +// WHY IT REPLACED A CONTENT PROBE, and the lesson is worth the paragraph. The first version of +// this arming condition was a CMake `file(STRINGS ... REGEX)` conjunction: "some source under +// MG_Remote/Client names the c1 symbols AND no source under Client or Server still matches +// `Fatal.Unimplemented`". Review finding M-1 did not argue with it, it PERFORMED it: a +// `sed -i 's/Fatal{Unimplemented/Fatal{NotYetImplemented/'` over c0's six stub files - every +// entry point still ending in std::abort(), RemoteEmitTable() still aborting on sight, +// ImplementedVerbCount() still returning 0 - armed all eleven lanes and EIGHT OF THEM WENT GREEN +// having run monolith end to end. A comment line containing the marker did the opposite and kept +// them dark forever (M-2), and two further stub files outside the two probed directories made a +// partial arm possible (M-3). +// +// The general form of that defect: A STATEMENT ABOUT SOURCE TEXT CAN ALWAYS BE FALSIFIED BY +// EDITING SOURCE TEXT, and the people most likely to edit it are the ones landing the packages +// the probe is watching for. A statement about what the process actually did cannot. So the three +// facts below are read out of the running process, and each is structurally impossible in a +// monolith build: +// +// 1. `MG_Config::Transport != Monolith`. In a build without MOBILEGL_BUILD_DISAGGREGATED, +// `Transport` is a `constexpr` Monolith (Config.h:514) and this whole translation unit is +// compiled out. Read from the VARIABLE, never from a log line - the log-grep spelling of +// this question is satisfied by a DEBUG-level pull build's `Config: Accepted env variable: +// MOBILEGL_TRANSPORT=inproc` (review finding M-5). +// 2. `ClientSession::Active() != nullptr`. c0 made this one deliberately return null rather +// than Fatal, because "does a session exist" has a legitimate "no" - it is the monolith +// answer (ClientSession.cpp:31). So it is exactly "a client session exists in this process", +// and no amount of editing stub MESSAGES makes a null pointer non-null. +// 3. `ImplementedVerbCount() > 0`. c0's stub returns 0; the contract gives this function the +// job of making "a table that silently lost an emitter" distinguishable from "a table that +// never had one" (EmitTables.h). Zero means there is no emitter to test. +// +// And one BEHAVIOURAL fact, which is the half that says the run went through the wire rather than +// merely that it could have: `ClientSession::Active()->Encoder().EmitSeq()`, the highest record +// ordinal this client has produced. A scenario that armed, drew, and emitted nothing has a +// sequence that did not move, and that is the shape of an emit table that resolves the transport +// and then falls through to the driver. +// +// Every entry point returns false, touching nothing, where the state is out of reach: in a build +// that never compiled MG_Remote, and on Android where this module links the shipping +// libMobileGL.so built -fvisibility=hidden. A caller that gets false must SKIP. + +#pragma once + +#include + +namespace MGITest { + + // What this process can say about itself. Every field is false/0 where the peek cannot look. + struct SplitRuntimeState { + // The peek is compiled in at all (MOBILEGL_BUILD_DISAGGREGATED, not Android). + bool peekAvailable = false; + // MG_Config::Transport != Monolith - this process RESOLVED a split transport. + bool transportResolved = false; + // The resolved transport, for a message: "monolith", "inproc", "spawn", "unix", "pipe". + std::string transportName = "monolith"; + // ClientSession::Active() != nullptr. + bool sessionActive = false; + // ImplementedVerbCount(), out of kRemoteEmitSlotCount (71). + unsigned int implementedVerbs = 0; + unsigned int totalVerbSlots = 0; + // The encoder's highest produced record ordinal, or 0 when there is no session. + unsigned long long emitSeq = 0; + + // ---- the wire producer's ledger, for exit gates E3(e) and R-10's proof obligation --- + // + // All four are 0 when there is no session, which is why every case that reads them has + // to have passed SplitRuntimeSkipReason() first: 0 wraps in a process that never had a + // ring and 0 wraps in a process whose ring never filled are the same number and + // completely different facts. + // + // maxRecordBytes / maxRecordBytesCap: R-10 says P5 does no chunking and must prove it + // needs none. The cap is RingProducer::MaxRecordBytes() == MOBILEGL_IPC_RING_MB / 2, + // read from the ring this process actually got rather than recomputed from the + // environment. + // + // cmdWraps / cmdWrapPads / cmdBytesWritten: SEG_CMD cannot go round until more bytes + // have been written than the ring holds, so the byte count is the denominator without + // which the wrap count means nothing - "0 wraps" is a defect after 1.25 MiB through a + // 1 MiB ring and a tautology after 40 KiB. cmdWrapPads is the narrower R-9 event (a + // record STRADDLED the boundary and needed a kRecPad filler) and is recorded rather + // than asserted: a uniform record stride over a power-of-two ring lands on the + // boundary exactly and never straddles it. + // + // stageReclaimWaits: allocations blocked after immediate reclamation failed; + // already-retired bytes reclaimed lazily do not count as a wait. + unsigned long long maxRecordBytes = 0; + unsigned long long maxRecordBytesCap = 0; + unsigned long long cmdWraps = 0; + unsigned long long cmdWrapPads = 0; + unsigned long long cmdBytesWritten = 0; + unsigned long long stageReclaimWaits = 0; + }; + + SplitRuntimeState PeekSplitRuntime(); + // Scheduling-only perturbation; never changes a watermark or counter. + void DelaySplitRetirementForTesting(bool enabled); + + // Empty when this process is a real split run that can be asserted about; otherwise the + // reason to GTEST_SKIP() with, naming the first fact that is not true and the package that + // owns it. + std::string SplitRuntimeSkipReason(); + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.h b/MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.h new file mode 100644 index 000000000..b14d11af3 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.h @@ -0,0 +1,149 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Harness/WireLedgerChecks.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 TWO ASSERTIONS THE WIRE PRODUCER'S LEDGER MAKES POSSIBLE, in one place so the scenarios +// that carry them cannot drift apart on what the numbers mean. +// +// R-10 (ExpectMaxRecordBytesUnderCap). P5 does no chunking and must instead PROVE it never +// needs any: no record may exceed RingProducer::MaxRecordBytes() == MOBILEGL_IPC_RING_MB / 2. +// Half of that proof is already a Fatal - PipeWireCodec.cpp aborts Fatal{RingOverrun} on a +// record ABOVE the cap - and it is the loud half. The quiet half is the one this assertion +// covers: the phase has to publish the MAXIMUM ACTUALLY SEEN on a real workload, so that a +// record creeping towards the cap is visible before the day it crosses. Until this landed the +// only readers of PipeWireEncoder::MaxRecordBytesSeen() were codec unit cases over synthetic +// records, and the joint gate recorded BRIEF 8 item 3 as "Maximum record bytes: NO MEASUREMENT" +// (joint-v1.md 5). +// +// R-9 / exit gate E3(e) (ExpectSmallRingWrappedAtLeastOnce). The SmallRing lane exists to run +// the reduced path over a ring small enough to force the wrap path - the kRecPad filler that +// both sides must SKIP WITHOUT ADVANCING seq, which is R-9's last clause and the one piece of +// ring behaviour no other lane reaches. It ran green from the day it was registered and proved +// nothing, because nobody counted: measured on the joint head, a whole split scenario writes +// about 40 KiB into SEG_CMD (emitseq 22-71, maxrec 784 bytes), so a 1 MiB "small" ring is 25 +// times larger than the traffic and the head never comes near its wrap boundary. The lane and +// the default lane were the same run under two names - which is exactly the shape ID-53's own +// comment warned about and t1-v1.md carried as a debt ("WHAT THIS LANE DOES NOT YET ASSERT"). +// +// So the lane now DRIVES ENOUGH WORK to overrun its own ring before it asserts, and the +// assertion prints the denominator with the count: "0 wraps" is a defect after 1.25 MiB through +// a 1 MiB ring and a tautology after 40 KiB, and a bare `EXPECT_GE(wraps, 1)` cannot tell those +// apart. The driving is ordinary GL - clears and draws through the scenario's own objects - and +// not a hand-built record: R-16 forbids an assertion that constructs the state it observes, and +// a test that called Reserve directly would be asserting that the ring wraps, not that the +// WORKLOAD makes it wrap. +// +// WHAT IS ASSERTED IS THE HEAD GOING ROUND, NOT THE kRecPad FILLER, and that distinction was +// forced by a measurement rather than chosen: the first cut of this assertion read the pad +// count, and 1310824 bytes of clears and draws through a 1 MiB SEG_CMD produced one and a half +// trips round the ring and ZERO pads. The reason is arithmetic, not a defect - a workload whose +// records repeat at a uniform stride that divides a power-of-two capacity lands on the boundary +// exactly, every time - so a gate written against the pad count would have been red for the +// sizes in the record catalogue and green the day one of them changed. The pad count is +// RECORDED beside the wrap count (ringpads= on the stats line, in the JUnit properties, and in +// the session's teardown ledger) so the number is available without being load-bearing. + +#pragma once + +#include + +#include + +#include "SplitLane.h" +#include "SplitRuntimePeek.h" + +namespace MGITest::WireLedger { + + // The SmallRing lane declares MOBILEGL_IPC_RING_MB=1 (MG_IntegrationTest/CMakeLists.txt; + // 1 MiB is ConfigLoader's floor for it). The byte target below is that size plus a + // quarter: enough that the head MUST have crossed the wrap boundary, and far enough under + // the DEFAULT 8 MiB ring that the same workload cannot wrap there - which is what makes + // "raise the ring back to the default and this assertion goes red" a real control rather + // than a description. + inline constexpr unsigned long long kSmallRingLaneCmdByteTarget = (5ull << 20) / 4; // 1.25 MiB + + // A bound on the drive loop, so a lane whose records shrank cannot spin forever. It is + // generous on purpose: the loop's exit condition is the BYTE COUNT, and this only turns an + // infinite loop into a named failure. + inline constexpr unsigned int kSmallRingLaneMaxIterations = 200000u; + + // R-10's reading, for any split lane. `where` names the case, because the number is a + // MEASUREMENT this phase has to publish and a reader needs to know which workload produced + // it. + inline void ExpectMaxRecordBytesUnderCap(const char* where) { + const SplitRuntimeState state = PeekSplitRuntime(); + ASSERT_TRUE(state.sessionActive) + << "the wire ledger was read in a process with no client session; the caller must " + "pass SplitLane::SkipReasonForSplitOnlyAssertions() first, because every field of " + "this ledger is 0 there and 0 is also a legal measurement"; + // Not merely "under the cap": a maximum of ZERO means the case emitted no record at all, + // which satisfies `< cap` perfectly and is the exact shape of an emit table that + // resolved the transport and then fell through to the driver. + EXPECT_GT(state.maxRecordBytes, 0u) + << where << ": the largest record this session wrote is 0 bytes, so nothing crossed " + "SEG_CMD. R-10's proof obligation has no subject and the lane did not go " + "through the wire"; + EXPECT_LT(state.maxRecordBytes, state.maxRecordBytesCap) + << where << ": R-10 - the largest record this session wrote is " << state.maxRecordBytes + << " bytes and RingProducer::MaxRecordBytes() is " << state.maxRecordBytesCap + << " (half of a " << (state.maxRecordBytesCap * 2) + << " byte SEG_CMD, i.e. MOBILEGL_IPC_RING_MB). P5 does NOT chunk: a record at or " + "above the cap is Fatal{RingOverrun} at the encoder, and a maximum that has " + "climbed to it is the proof obligation failing. Report it to the integrator, who " + "decides between early chunking (P8) and a bigger default ring"; + ::testing::Test::RecordProperty("max_record_bytes", + static_cast(state.maxRecordBytes)); + ::testing::Test::RecordProperty("max_record_bytes_cap", + static_cast(state.maxRecordBytesCap)); + } + + // E3(e)'s reading. The CALLER drives the workload; this only reads the result, so that the + // thing being asserted about is the workload and not this header. + inline void ExpectSmallRingWrappedAtLeastOnce(const char* where, + unsigned long long bytesDriven) { + const SplitRuntimeState state = PeekSplitRuntime(); + ASSERT_TRUE(state.sessionActive) + << "the wire ledger was read in a process with no client session"; + const unsigned long long capacity = state.maxRecordBytesCap * 2; // MaxRecordBytes == cap/2 + // THE WRAP FIRST, AND THE DENOMINATOR RIGHT BEHIND IT, both as EXPECT so that a red + // carries both sentences. Order matters for what the failure SAYS: the thing this gate + // is about is the missing wrap, and "the loop pushed fewer bytes than the ring holds" + // is the EXPLANATION for it, not a different failure. An ASSERT on the denominator + // would print only the explanation and the reader would have to infer the gate - which + // is how the red-once line for this control was measured, and why it is written this + // way round. + EXPECT_GE(state.cmdWraps, 1u) + << where << ": exit gate E3(e) - " << bytesDriven << " bytes were written into a " + << capacity + << " byte SEG_CMD and the producer's head NEVER WENT ROUND: no wrap, so this entry " + "exercised exactly what the default lane exercises and the word SmallRing in its " + "name asserts nothing. That is what the joint gate recorded as 'SmallRing entries " + "ran, but no back-pressure wait count was measured' (joint-v1.md 6). The usual " + "cause is the ring: MOBILEGL_IPC_RING_MB did not reach this process, or the lane " + "was given the DEFAULT 8 MiB ring - which is exactly how this assertion was " + "proved to be load-bearing (R-16), by re-running this entry's own command with " + "MOBILEGL_IPC_RING_MB=8 and nothing else changed"; + EXPECT_GT(bytesDriven, capacity) + << where << ": and the reason is the denominator - the drive loop pushed only " + << bytesDriven << " bytes through a " << capacity + << " byte SEG_CMD, which cannot reach a wrap boundary at all. The loop stops at " + "kSmallRingLaneCmdByteTarget, which is sized for the 1 MiB ring this lane " + "declares (MGL_ITEST_GLES_SPLIT_SMALL_RING_ENVIRONMENT); a larger ring needs a " + "larger workload and is not what this lane is for"; + EXPECT_GE(state.stageReclaimWaits, 1u) + << where << ": exit gate E3(e) - producer NEVER WAITED for staging retirement; " + "lazy reclamation of already-retired bytes is not back-pressure"; + ::testing::Test::RecordProperty("ring_wraps", static_cast(state.cmdWraps)); + ::testing::Test::RecordProperty("ring_wrap_pads", static_cast(state.cmdWrapPads)); + ::testing::Test::RecordProperty("ring_waits", static_cast(state.stageReclaimWaits)); + ::testing::Test::RecordProperty("cmd_bytes_written", static_cast(bytesDriven)); + } + + // Bytes the producer has written so far, or 0 outside a split process. + inline unsigned long long CmdBytesWritten() { return PeekSplitRuntime().cmdBytesWritten; } + +} // namespace MGITest::WireLedger diff --git a/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py new file mode 100644 index 000000000..9299a5ae4 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/split_log_paths.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Validate discovered Split log ownership; read only freshly reset control logs.""" +import os +import json +from pathlib import Path +import re +import subprocess +import sys +import xml.etree.ElementTree as ET + + +def paths(document): + owners = {} + split = {} + for test in document["tests"]: + props = {p["name"]: p["value"] for p in test.get("properties", [])} + values = [v.split("=", 1)[1] for v in props.get("ENVIRONMENT", []) + if v.startswith("MOBILEGL_LOG_FILE_PATH=")] + is_split = test["name"].startswith("DirectGLES.Split.") + if is_split and (len(values) != 1 or not values[0]): + raise ValueError(f"{test['name']}: requires exactly one nonempty MOBILEGL_LOG_FILE_PATH") + for value in values: + path = str(Path(value).resolve()) + owners.setdefault(path, []).append(test["name"]) + if is_split: + if not Path(value).is_absolute(): + raise ValueError(f"{test['name']}: MOBILEGL_LOG_FILE_PATH must be absolute: {value}") + split[test["name"]] = path + if not split: + raise ValueError("integration-split: no entries discovered") + for name, path in split.items(): + if len(owners[path]) != 1: + raise ValueError(f"{name}: duplicate MOBILEGL_LOG_FILE_PATH {path}: {owners[path]}") + return split + + +# P5e (gl), ID-119: THE MARKER GRAMMAR, PARSED WHERE IT IS WRITTEN. +# +# MG_Backend/MGPipe/PipeInputs.cpp writes exactly two shapes and they differ only in the leading +# tag and the reason, which is what lets every filter written since P5c keep meaning "red": +# +# MGPipe: Fatal{UnmigratedPipeInput, "@"} [BARRIER-PULLED, , retires in ] +# MGPipe: Admitted{UnmigratedPipeInput, "@"} [BARRIER-PULLED, ADMITTED[-ESCALATED], retires in ] +MARKER_RE = re.compile( + r"(Fatal|Admitted)\{UnmigratedPipeInput,\s*\"([^\"@]+)@([^\"]+)\"\}" + r"(?:\s*\[BARRIER-PULLED,\s*([^,\]]+))?") + + +def marker_log_paths(document): + """{entry: absolute MOBILEGL_LOG_FILE_PATH} for EVERY entry that declares one. + + P5e (gl), ID-119: THE LOG SET COMES FROM ctest, NOT FROM A DIRECTORY. The CI step used to + grep MobileGL/MG_IntegrationTest/split-logs/, which holds 90 of the lane's entries. The ones + it missed were the F1. readback block, both NamedBlit pairs, the four Ct. entries and + PersistentMapArm - i.e. precisely the readback population the allowlist is ABOUT, plus the + only DirectVulkan entries in the lane. A directory is a guess about where the lane put its + logs; `ctest --show-only=json-v1` is the lane saying where it put them. + + Unlike paths() above this does not restrict itself to DirectGLES.Split. entries and does not + validate ownership - that is paths()' job and SplitLogPaths.PrivateAndDistinct runs it. This + one answers "which files might carry a marker", for every backend prefix the lane has.""" + logs = {} + for test in document["tests"]: + props = {p["name"]: p["value"] for p in test.get("properties", [])} + values = [v.split("=", 1)[1] for v in props.get("ENVIRONMENT", []) + if v.startswith("MOBILEGL_LOG_FILE_PATH=")] + if len(values) != 1 or not values[0]: + continue + logs[test["name"]] = str(Path(values[0]).resolve()) + return logs + + +def read_pair_set(path): + """A committed `@` set, one per line; blank lines and # comments ignored.""" + pairs = set() + for line in Path(path).read_text().splitlines(): + line = line.split("#", 1)[0].strip() + if line: + pairs.add(line) + return pairs + + +def classify_markers(logs): + """(fatal, admitted, escalated, scanned) - each a {pair: sorted entry names}. + + Counted BY ENTRY and not by line: the runtime dedupes each (field, verb) once per process, + so a line count would only ever say how many processes ran.""" + fatal, admitted, escalated = {}, {}, {} + scanned = [] + for name in sorted(logs): + path = logs[name] + if not Path(path).is_file(): + continue + scanned.append(name) + text = Path(path).read_text(errors="replace") + for tag, field, verb, why in MARKER_RE.findall(text): + pair = f"{field}@{verb}" + if tag == "Fatal": + fatal.setdefault(pair, set()).add(name) + elif (why or "").strip() == "ADMITTED-ESCALATED": + escalated.setdefault(pair, set()).add(name) + else: + admitted.setdefault(pair, set()).add(name) + return ({p: sorted(e) for p, e in fatal.items()}, + {p: sorted(e) for p, e in admitted.items()}, + {p: sorted(e) for p, e in escalated.items()}, + scanned) + + +def print_marker_table(title, table): + print(f" {title}: {len(table)} distinct pair(s)") + for pair in sorted(table, key=lambda p: (-len(table[p]), p)): + print(f" {len(table[pair]):4d} {pair}") + + +def markers(document, selector, allowed_path, expected_path, require_no_fatal): + """THE TWO-SIDED RATCHET (ID-119). + + One side: a marker that is not admitted fails the lane. That half existed in spirit but was + unreachable code, because strict aborted on every barrier-pulled read and the run's rc took + the step out before the comparison (ID-117 fixed the knob; this reads the result). + + The other side, which is the new half: an admitted pair that NO LONGER APPEARS must be + removed from the expected set. Without it the lane rots green - a debt retires, its marker + stops being written, and the expected list quietly becomes a list of things that used to + happen, so the next regression to re-introduce one of them reads as "expected".""" + logs = {name: path for name, path in marker_log_paths(document).items() + if re.search(selector, name)} + if not logs: + raise ValueError(f"no lane entry matching /{selector}/ declares a " + "MOBILEGL_LOG_FILE_PATH, so there is nothing to read markers out of") + fatal, admitted, escalated, scanned = classify_markers(logs) + print(f"SplitLogPaths markers: {len(scanned)} of {len(logs)} selected entries wrote a log") + print_marker_table("Fatal{UnmigratedPipeInput", fatal) + print_marker_table("Admitted{UnmigratedPipeInput", admitted) + print_marker_table("Admitted{UnmigratedPipeInput (ESCALATED)", escalated) + if not scanned: + raise ValueError("not one selected entry wrote its private log: the marker census is " + "EMPTY because nothing was read, which is not the same statement as " + "'no marker fired' and must never be reported as one") + + problems = [] + if require_no_fatal and fatal: + problems.append("%d Fatal marker(s) - each is a field an apply still reads out of client " + "memory with nothing admitting it: %s" + % (len(fatal), "; ".join(f"{p} ({len(e)} entries, e.g. {e[0]})" + for p, e in sorted(fatal.items())))) + + # An ADMITTED (non-escalated) marker claims the GENERATED table admitted it, so it must be in + # that table. An ADMITTED-ESCALATED one does not: it is a runtime fact about the record's + # payload (an open XFB span, a draw with client arrays) that no table indexed by + # (field, verb) can carry, so looking for it in a static list would mean widening that list + # with every pair that could ever escalate - which would forgive the ordinary draw path too. + allowed = read_pair_set(allowed_path) + outside = sorted(set(admitted) - allowed) + if outside: + problems.append("%d marker(s) tagged ADMITTED are not in the generated allowlist, so the " + "committed PipeFieldOwnership.inc and the generator disagree: %s" + % (len(outside), ", ".join(outside))) + + expected = read_pair_set(expected_path) + observed = set(admitted) | set(escalated) + appeared = sorted(observed - expected) + vanished = sorted(expected - observed) + if appeared: + problems.append("%d admitted pair(s) the lane has not seen before: %s. Each is a debt " + "somebody now owes - add it to %s with the phase that retires it, or " + "retire it." + % (len(appeared), ", ".join(appeared), Path(expected_path).name)) + if vanished: + problems.append("%d expected pair(s) no longer appear: %s. Remove them from %s in the " + "commit that retired them - an expected set that keeps rows nothing " + "writes any more is how this lane rots green." + % (len(vanished), ", ".join(vanished), Path(expected_path).name)) + if problems: + raise ValueError("the strict lane's marker ratchet: " + " | ".join(problems)) + print(f"SplitLogPaths markers: ratchet OK - {len(observed)} admitted pair(s), " + f"{len(fatal)} fatal pair(s)") + + +def main(): + mode = sys.argv[1] + if mode == "check": + data = subprocess.check_output([sys.argv[2], "--test-dir", sys.argv[3], + "--show-only=json-v1"], text=True) + selected = paths(json.loads(data)) + print(f"SplitLogPaths: {len(selected)} entries, {len(set(selected.values()))} distinct private paths") + return + if mode == "markers": + markers(json.loads(Path(sys.argv[2]).read_text()), sys.argv[3], sys.argv[4], sys.argv[5], + len(sys.argv) > 6 and sys.argv[6] == "no-fatal") + return + selected = paths(json.loads(Path(sys.argv[2]).read_text())) + selected = {name: path for name, path in selected.items() if re.search(sys.argv[3], name)} + # The SAME exclusion run_control hands ctest -E. Without it this helper and ctest disagree + # about the subject set, and the helper reports an entry ctest never ran as "did not run" + # (P5e, ID-122). ctest -R is POSIX ERE and cannot express the exclusion inline. + _excl = os.environ.get("SPLIT_LOG_EXCLUDE", "") + if _excl: + selected = {n: q for n, q in selected.items() if not re.search(_excl, n)} + if not selected: + raise ValueError(f"integration-split: empty selection for {sys.argv[3]}") + if mode == "reset": + for path in selected.values(): + Path(path).unlink(missing_ok=True) + elif mode == "results": + cases = ET.parse(sys.argv[4]).getroot().findall(".//testcase") + label = sys.argv[5] + by_name = {} + for case in cases: + by_name.setdefault(case.get("name"), []).append(case) + skipped = sum(any(c.find("skipped") is not None for c in by_name.get(n, [])) + for n in selected) + # ID-62: pre-flight Fatal is not evidence that a selected entry ran. + if skipped: + raise ValueError(f"{label} control: the knob killed the pre-flight, not the entry - " + f"{skipped} selected entries skipped") + missing = sum(len(by_name.get(n, [])) != 1 or + by_name[n][0].get("status") in ("notrun", "disabled") for n in selected) + if missing: + raise ValueError(f"{label} control: {missing} selected entries did not run") + not_failed = sum(c.find("failure") is None or c.get("status") != "fail" + for n in selected for c in by_name[n]) + if not_failed: + raise ValueError(f"{label} control: {not_failed} selected entries did not fail") + elif mode == "assertion": + cases = ET.parse(sys.argv[4]).getroot().findall(".//testcase") + by_name = {case.get("name"): case for case in cases} + for name in selected: + case = by_name.get(name) + output = "" if case is None else " ".join(" ".join(case.itertext()).split()) + if not re.search(sys.argv[5], output): + raise ValueError(f"E3(a) FAILED: {name} red lacks its persistent-map push diagnostic") + elif mode == "evidence": + missing = [] + label = sys.argv[5] if len(sys.argv) > 5 else "" + for name, path in selected.items(): + if Path(path).is_file() and re.search(sys.argv[4], Path(path).read_text(errors="replace")): + print(f"private-log evidence: {name}: {path}") + else: + missing.append(f"{name} ({path})") + if missing: + if label: + raise ValueError(f"{label} FAILED: no selected private log carries /{sys.argv[4]}/. " + "The library's own line is the only channel for this: ctest's " + "transcript is a FALSE ZERO for library output, because the console " + "sink is compiled out of the configurations these lanes run. " + + ", ".join(missing)) + raise ValueError("E1 FAILED: selected private logs lack expected Fatal{BarrierViolation, \"\"} line: " + + ", ".join(missing)) + else: + raise ValueError(f"unknown mode: {mode}") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, ET.ParseError, subprocess.CalledProcessError) as error: + sys.exit(f"SplitLogPaths FAILED: {error}") diff --git a/MobileGL/MG_IntegrationTest/Harness/strict-expected-markers.txt b/MobileGL/MG_IntegrationTest/Harness/strict-expected-markers.txt new file mode 100644 index 000000000..09db9ad0d --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Harness/strict-expected-markers.txt @@ -0,0 +1,52 @@ +# The admitted barrier-pull markers the integration-split lane ACTUALLY WRITES under +# MOBILEGL_IPC_STRICT_ERRORS=1, one `@` per line. +# +# P5e (gl), ruling ID-119. THIS IS THE OTHER SIDE OF THE RATCHET, and it is the side that was +# missing. The generated allowlist (scripts/gen_pipe_field_ownership.py --print-admitted) says +# which pairs MAY be admitted - it is a statement about the tables. This file says which ones the +# lane DOES produce - a statement about the run. A lane checked only against the first kind +# ROTS GREEN: a debt retires, its marker stops being written, and nothing notices, so the day a +# regression re-introduces that read the lane calls it "expected". +# +# So Harness/split_log_paths.py's `markers` mode fails BOTH ways: +# - a pair here that no longer appears -> delete it, in the commit that retired it; +# - a pair that appears and is not here -> add it with the phase that owes it, or retire it. +# +# HOW TO REFRESH (never by hand from a guess): +# ctest -L integration-split --show-only=json-v1 > lane.json +# MOBILEGL_IPC_STRICT_ERRORS=1 ctest -L integration-split -j 4 +# python3 MobileGL/MG_IntegrationTest/Harness/split_log_paths.py markers lane.json . \ +# <(python3 scripts/gen_pipe_field_ownership.py --print-admitted) this-file +# and put the census it prints here. +# +# --------------------------------------------------------------------------------------------- +# RE-MEASURED ON THE WAVE-3 INTEGRATION TREE (pa + mv + gl merged), 181 lane entries, 181 green, +# ZERO Fatal pairs. The note this block used to carry - that it was measured at gl's base and the +# merge would retire three rows - was discharged exactly as written: the ratchet named +# GetProgramForDraw@DrawArrays as an expected pair that no longer appears, and named +# GetBufferBindingSlot@DrawArrays as one the lane had not seen before. Both are reconciled below. +# GetProgramForDispatch@DispatchCompute never reached this file (pa retired it before it was +# written) and the ordinary GetBoundVertexArray@DrawArrays is gone; the two that remain under that +# name are the client-vertex-array entries, which are escalation-barriered and P8's. +# --------------------------------------------------------------------------------------------- +# +# Admitted by the generated table (disjuncts 1 and 2 of ID-116 / ID-125): +GetBufferBindingSlot@ReadPixels +# NEW IN WAVE 3, and it was named before it could fire: mv's report called +# BoundDrawIndirectBufferId's GetBufferBindingSlot(DrawIndirect) a latent row that "would surface +# as GetBufferBindingSlot@DrawArrays if an indirect tier were ever forced under strict", and then +# gated the five multi-draw tiers, which forced it. Retires in P8 (FieldOwnership.def:78). +GetBufferBindingSlot@DrawArrays +GetFramebufferBindingSlot@ReadPixels +GetProgramObject@ShaderStorageBlockBinding +GetTextureObject@CopyImageSubData +GetTextureUnitObject@CopyTexImage2D +GetTransformFeedbackProgram@DrawArrays +ValidateProgramName@ShaderStorageBlockBinding +# +# Admitted by ESCALATION only (disjunct 3, ID-128): the record was barriered because its payload +# said so - an open transform-feedback span, or a draw carrying client vertex arrays - which no +# table indexed by (field, verb) can see. All three are pulls on the draw verb by entries that +# are NOT the ordinary draw path. +GetBoundVertexArray@DrawArrays +GetBufferBindingPoint@DrawArrays diff --git a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp index 5ee416491..479683a34 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/AdvertisedLimitsScenario.cpp @@ -26,9 +26,11 @@ // quantities, so an entry that only fails on DirectVulkan is a translation bug and one that // fails on both is a table bug. +#include #include #include +#include "../Harness/BackendCapsPeek.h" #include "../Harness/HeadlessGL.h" #include "../Harness/ScenarioFixture.h" @@ -378,5 +380,250 @@ namespace MGITest { EXPECT_GE(viewportDims[1], maxRenderbufferSize); } + + // THE INDEXED AND PER-PROGRAM QUERIES THAT NAME FRONTEND STATE, pinned on both lanes. + // + // Both backends used to carry their own arms for GL_SHADER_STORAGE_BUFFER_* and + // GL_IMAGE_BINDING_* inside GLFunctionsTable::GetIntegeri_v, and their own + // GetInteger64i_v / GetProgramiv table entries. None of it was reachable: GL_Getter and + // GL_Program answer every one of these pnames from the frontend's own state and return + // before the table is consulted. The duplicates did not even agree - the backend arms + // clamped a bound range to the buffer's current storage, which GL 4.6 core tables + // 23.4/23.5 do not permit - so the code was one refactor away from becoming the answer. + // These cases pin what the frontend actually reports, so a future move of any of it back + // behind the interface has to keep saying the same thing. + TEST_F(AdvertisedLimitsScenario, IndexedBufferBindingsAreReportedVerbatimOnBothWidths) { + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer); + glBufferData(GL_SHADER_STORAGE_BUFFER, 1024, nullptr, GL_DYNAMIC_DRAW); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + // A range that is NOT the whole buffer, so a clamp to the store would be visible. + glBindBufferRange(GL_SHADER_STORAGE_BUFFER, 1, buffer, 256, 512); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + GLint binding32 = -1; + GLint start32 = -1; + GLint size32 = -1; + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_BINDING, 1, &binding32); + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start32); + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size32); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(binding32, static_cast(buffer)); + EXPECT_EQ(start32, 256); + EXPECT_EQ(size32, 512); + + // The 64-bit width has to agree pname for pname. It has no backend entry of its own + // and derives everything from the 32-bit answer above plus its own buffer arm. + GLint64 binding64 = -1; + GLint64 start64 = -1; + GLint64 size64 = -1; + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_BINDING, 1, &binding64); + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_START, 1, &start64); + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &size64); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(binding64, static_cast(buffer)); + EXPECT_EQ(start64, static_cast(256)); + EXPECT_EQ(size64, static_cast(512)); + + // An unbound index answers zero rather than erroring or leaking the driver's answer. + GLint unbound = -1; + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_BINDING, 0, &unbound); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(unbound, 0); + + // THE ARM THAT SEPARATES VERBATIM FROM CLAMPED. GL 4.6 core tables 23.4/23.5 report + // the size glBindBufferRange was ASKED for; it does not follow the buffer, so + // shrinking the store underneath the binding must not move it. A clamp to the + // current storage - which is exactly what both backends' deleted arms did - answers + // 128 here, and answers 0 for the bind-then-allocate shape + // KHR-GL43.shader_storage_buffer_object.basic-binding uses. + glBufferData(GL_SHADER_STORAGE_BUFFER, 128, nullptr, GL_DYNAMIC_DRAW); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + GLint startAfterShrink = -1; + GLint sizeAfterShrink = -1; + GLint64 sizeAfterShrink64 = -1; + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_START, 1, &startAfterShrink); + glGetIntegeri_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &sizeAfterShrink); + glGetInteger64i_v(GL_SHADER_STORAGE_BUFFER_SIZE, 1, &sizeAfterShrink64); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(startAfterShrink, 256) + << "the bound range's start followed the buffer through a re-specification"; + EXPECT_EQ(sizeAfterShrink, 512) + << "the bound range's size was clamped to the buffer's current 128-byte storage; the range is " + "state of the BINDING POINT and is reported verbatim"; + EXPECT_EQ(sizeAfterShrink64, static_cast(512)) + << "the 64-bit width disagreed with the 32-bit one about the same pname"; + + glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, 0); + glDeleteBuffers(1, &buffer); + (void)FirstGLError(); + } + + TEST_F(AdvertisedLimitsScenario, ImageUnitBindingsAreReportedFromTheFrontendState) { + GLint maxImageUnits = 0; + glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits); + (void)FirstGLError(); + if (maxImageUnits < 2) GTEST_SKIP() << "no image units to bind on this lane"; + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexStorage2D(GL_TEXTURE_2D, 2, GL_RGBA8, 8, 8); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + glBindImageTexture(1, texture, 1, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + struct Expectation { + GLenum pname; + const char* name; + GLint expected; + }; + const Expectation expectations[] = { + {GL_IMAGE_BINDING_NAME, "GL_IMAGE_BINDING_NAME", static_cast(texture)}, + {GL_IMAGE_BINDING_LEVEL, "GL_IMAGE_BINDING_LEVEL", 1}, + {GL_IMAGE_BINDING_LAYERED, "GL_IMAGE_BINDING_LAYERED", GL_FALSE}, + {GL_IMAGE_BINDING_LAYER, "GL_IMAGE_BINDING_LAYER", 0}, + {GL_IMAGE_BINDING_ACCESS, "GL_IMAGE_BINDING_ACCESS", GL_READ_ONLY}, + {GL_IMAGE_BINDING_FORMAT, "GL_IMAGE_BINDING_FORMAT", GL_RGBA8}, + }; + for (const Expectation& expectation : expectations) { + GLint value = -424242; + glGetIntegeri_v(expectation.pname, 1, &value); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << expectation.name; + EXPECT_EQ(value, expectation.expected) << expectation.name; + + // Same pname through the wide width - it must not fall through to a driver that + // knows nothing about MobileGL's image-unit state. + GLint64 wide = -424242; + glGetInteger64i_v(expectation.pname, 1, &wide); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << expectation.name << " (64-bit)"; + EXPECT_EQ(wide, static_cast(expectation.expected)) << expectation.name << " (64-bit)"; + } + + glBindImageTexture(1, 0, 0, GL_FALSE, 0, GL_READ_ONLY, GL_RGBA8); + glDeleteTextures(1, &texture); + (void)FirstGLError(); + } + + // glGetProgramiv(GL_COMPUTE_WORK_GROUP_SIZE) is a LINK ARTIFACT of the program the + // application wrote. DirectVulkan used to answer it from its own spirv-reflect cache and + // DirectGLES by forwarding to the driver's ESSL program - neither of which the + // application ever named - while GL_Program.cpp has always answered it from + // ProgramObject::GetComputeLocalSize. This pins the declared local size on both lanes. + TEST_F(AdvertisedLimitsScenario, ComputeLocalSizeComesFromTheLinkedProgram) { + static const char* kSource = R"(#version 430 core +layout(local_size_x = 4, local_size_y = 3, local_size_z = 2) in; +layout(std430, binding = 0) buffer Output { uint g_data[]; }; +void main() { g_data[gl_LocalInvocationIndex] = 1u; } +)"; + const GLuint shader = glCreateShader(GL_COMPUTE_SHADER); + glShaderSource(shader, 1, &kSource, nullptr); + glCompileShader(shader); + GLint compiled = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); + if (compiled == GL_FALSE) { + char log[2048] = {}; + glGetShaderInfoLog(shader, sizeof(log) - 1, nullptr, log); + glDeleteShader(shader); + (void)FirstGLError(); + GTEST_SKIP() << "no compute shader support on this lane: " << log; + } + const GLuint program = glCreateProgram(); + glAttachShader(program, shader); + glLinkProgram(program); + glDeleteShader(shader); + GLint linked = 0; + glGetProgramiv(program, GL_LINK_STATUS, &linked); + if (linked == GL_FALSE) { + char log[2048] = {}; + glGetProgramInfoLog(program, sizeof(log) - 1, nullptr, log); + glDeleteProgram(program); + (void)FirstGLError(); + GTEST_SKIP() << "the compute program did not link on this lane: " << log; + } + + GLint localSize[3] = {-1, -1, -1}; + glGetProgramiv(program, GL_COMPUTE_WORK_GROUP_SIZE, localSize); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_EQ(localSize[0], 4); + EXPECT_EQ(localSize[1], 3); + EXPECT_EQ(localSize[2], 2); + + // A program with no compute stage must answer INVALID_OPERATION, not a stale or + // defaulted (1, 1, 1) - the frontend's rule, and the one a backend that answers from + // its own reflection cache cannot express. + const GLuint empty = glCreateProgram(); + GLint ignored[3] = {0, 0, 0}; + glGetProgramiv(empty, GL_COMPUTE_WORK_GROUP_SIZE, ignored); + EXPECT_EQ(FirstGLError(), GLenum(GL_INVALID_OPERATION)) + << "GL 4.6 core 7.13: the query is only defined for a linked program with a compute shader"; + + glDeleteProgram(empty); + glDeleteProgram(program); + (void)FirstGLError(); + } + + // THE SIX COMPUTE LIMITS THAT OUTLIVE THE GETTER. GL_MAX_COMPUTE_WORK_GROUP_COUNT and + // GL_MAX_COMPUTE_WORK_GROUP_SIZE, three axes each, are the only indexed pnames the + // DEVICE answers rather than the frontend (glGetIntegeri_v on Espryt, VkPhysicalDevice- + // Limits on Magma), and therefore the only ones that have to cross the MGPipe boundary + // once GetIntegeri_v is retired (plan B section 4.4.6 / P0.5). They ride in MGPCaps by + // inclusion, as DynamicBackendParameters::MaxComputeWorkGroupCount/Size, filled by both + // backends at capability init. This case pins that the caps copy and the live getter + // answer are one number - the getter floors the backend's raw answer at the GL 4.3 + // minimum, so the comparison is against the floored caps value - and pins the + // GL-visible half on every lane: answerability, the floors, vector/indexed agreement + // and the index bound. On a lane where the caps block is out of reach (Android links + // the shipping .so) only the GL-visible half runs. + TEST_F(AdvertisedLimitsScenario, ComputeWorkGroupLimitsAreTheCapsBlocksAnswer) { + struct Axis { + GLenum pname; + const char* name; + GLint minimum[3]; // GL 4.3 core table 23.60 + }; + const Axis axes[] = { + {GL_MAX_COMPUTE_WORK_GROUP_COUNT, "GL_MAX_COMPUTE_WORK_GROUP_COUNT", {65535, 65535, 65535}}, + {GL_MAX_COMPUTE_WORK_GROUP_SIZE, "GL_MAX_COMPUTE_WORK_GROUP_SIZE", {1024, 1024, 64}}, + }; + int capsCount[3] = {0, 0, 0}; + int capsSize[3] = {0, 0, 0}; + const bool capsVisible = PeekComputeWorkGroupCaps(capsCount, capsSize); + + for (const Axis& axis : axes) { + GLint indexed[3] = {-1, -1, -1}; + for (GLuint i = 0; i < 3; ++i) { + glGetIntegeri_v(axis.pname, i, &indexed[i]); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << axis.name << "[" << i << "]"; + EXPECT_GE(indexed[i], axis.minimum[i]) + << axis.name << "[" << i << "] = " << indexed[i] + << " is below the GL 4.3 core table 23.60 minimum " << axis.minimum[i]; + } + GLint vector[3] = {-1, -1, -1}; + glGetIntegerv(axis.pname, vector); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << axis.name; + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(vector[i], indexed[i]) + << axis.name << "[" << i << "]: the vector query and the indexed query disagree"; + } + GLint outOfRange = -424242; + glGetIntegeri_v(axis.pname, 3, &outOfRange); + EXPECT_EQ(FirstGLError(), GLenum(GL_INVALID_VALUE)) + << axis.name << "[3]: an index past the three axes is INVALID_VALUE (GL 4.6 core 22.1)"; + + if (!capsVisible) continue; + const int* capsAxis = axis.pname == GL_MAX_COMPUTE_WORK_GROUP_COUNT ? capsCount : capsSize; + for (int i = 0; i < 3; ++i) { + EXPECT_EQ(std::max(capsAxis[i], axis.minimum[i]), indexed[i]) + << axis.name << "[" << i << "]: MGPCaps carries " << capsAxis[i] + << " but glGetIntegeri_v answers " << indexed[i] + << " - the caps block and the getter path must be one number, because P0.5 retires " + "the getter in favour of the caps"; + } + } + } + } // namespace } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ClientVertexArrayScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ClientVertexArrayScenario.cpp new file mode 100644 index 000000000..ac615a743 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ClientVertexArrayScenario.cpp @@ -0,0 +1,189 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/ClientVertexArrayScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - A VERTEX ARRAY THAT LIVES IN THE APPLICATION'S OWN MEMORY, DRAWN OVER THE WIRE. +// +// WHY THIS FILE EXISTS AT ALL (P5e vi, ID-82 / BRIEF-P5E §6 open item (a)). The brief asked +// whether any existing scenario exercises a client-memory vertex array under split, and named +// DoublePrecisionScenario as the candidate. IT DOES NOT, and nothing else does either: every +// glVertexAttribPointer in this directory is issued with a buffer bound to GL_ARRAY_BUFFER, so +// its last argument is a byte OFFSET and not a host pointer (DoublePrecisionScenario uses the +// binding-model calls against real buffer objects, DoublePrecisionScenario.cpp:944-959). So the +// one draw shape whose bytes have no wire form was, until this file, never drawn over the wire +// in any lane. +// +// WHAT IT PINS, in the two arms it can be in: +// +// TODAY (lockstep, and the monolith control) the draw is NOT refused - ruling 4: the client +// is parked in WaitForApplied for exactly this record, so the server's per-draw upload of the +// application's bytes (Managers.cpp's SyncClientSideAttributesForDrawArrays, the kimi audit's +// row 14) reads memory that is not moving. The assertion is therefore about PIXELS: the quad +// has to arrive. That is what makes vi's "monolith-only would have been wrong here" concrete +// rather than an argument in a report - delete the split arm of the upload and this goes red. +// +// UNDER RUN-AHEAD it is REFUSED BY NAME on the GL thread, Fatal{UnmigratedVerb, +// "DrawArrays+CLIENT_ARRAYS"} (CONTRACT-P5E §5.1), because the client is a frame ahead and +// those bytes are moving under the reader. This file is the red-once for that refusal: it is +// the only lane entry that can reach it. Staging the bytes as a per-attribute {BindingIndex, +// MGHostSpan} tail - the shape kDrawHasUserIndices already has for client INDICES - is P8's, +// and retires the refusal along with this scenario's second half. +// +// Both draw entry points that carry the upload are driven, because they are two arms and not +// one: DrawArrays uploads the single range, MultiDrawArrays uploads one range per sub-draw. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + constexpr const char* kFS = R"(#version 330 core +out vec4 o_color; +void main() { o_color = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + class ClientVertexArrayScenario : public ScenarioTest {}; + + // THE WHOLE POINT IS THE ABSENCE OF A BUFFER. glBindBuffer(GL_ARRAY_BUFFER, 0) before + // glVertexAttribPointer makes the last argument a HOST POINTER rather than an offset, + // which is the one vertex source EmitVertexBuffers publishes as Res == + // kMGPipeNullHandle - "not a hole: it is exactly how the server learns this attribute + // is client-sourced, upload it yourself" (VertexInputEmit.h). + // + // A VAO is still bound, because ES core requires one and because the server's upload + // hangs off the VAO's twin. + struct ClientArrayQuad { + GLuint vao = 0; + const float* vertices = nullptr; + + void Bind(const float* quad) { + vertices = quad; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), vertices); + } + void Release() { + glBindVertexArray(0); + if (vao != 0) glDeleteVertexArrays(1, &vao); + vao = 0; + } + }; + + // Full-viewport strip, so "did the draw arrive" is one pixel read rather than a shape + // comparison - the claim is about the vertex SOURCE, not about rasterisation. + const float kQuad[] = {-1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f}; + + Rgba8 CentreAfter(int width, int height) { + const Image image = ReadPixelsRect(0, 0, width, height); + return image.At(width / 2, height / 2); + } + + } // namespace + + TEST_F(ClientVertexArrayScenario, AClientMemoryVertexArrayReachesTheDrawItFeeds) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 1.0f, 1.0f); + + ClientArrayQuad quad; + quad.Bind(kQuad); + glUseProgram(program); + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + const Rgba8 centre = CentreAfter(width, height); + EXPECT_GT(static_cast(centre.g), 200) + << "the client-memory vertex array never reached the draw: the centre is " << centre + << ", i.e. still the clear colour. Under split this means the per-draw upload of " + "the application's own bytes did not run - the arm CONTRACT-P5E §5.1 keeps alive " + "for lockstep, and refuses only under run-ahead"; + EXPECT_LT(static_cast(centre.b), 60) + << "the quad drew, but the clear colour is still showing through: " << centre; + + quad.Release(); + glUseProgram(0); + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u); + } + + TEST_F(ClientVertexArrayScenario, EverySubDrawOfAMultiDrawArraysGetsItsOwnClientRange) { + if (!Ready()) return; + HeadlessGL& gl = Gl(); + const int width = gl.Width(); + const int height = gl.Height(); + + std::string error; + const unsigned int program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(program, 0u) << error; + + BindDefaultFramebuffer(); + glViewport(0, 0, width, height); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 1.0f, 1.0f); + + // Two strips out of one client array: the left half and the right half, so a + // MultiDrawArrays that uploaded only the FIRST sub-draw's range leaves one side blue. + static const float kTwoHalves[] = { + -1.0f, -1.0f, 0.0f, -1.0f, -1.0f, 1.0f, 0.0f, 1.0f, + 0.0f, -1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f, + }; + ClientArrayQuad quad; + quad.Bind(kTwoHalves); + glUseProgram(program); + + const GLint firsts[2] = {0, 4}; + const GLsizei counts[2] = {4, 4}; + glMultiDrawArrays(GL_TRIANGLE_STRIP, firsts, counts, 2); + + const Image image = ReadPixelsRect(0, 0, width, height); + const Rgba8 left = image.At(width / 4, height / 2); + const Rgba8 right = image.At((3 * width) / 4, height / 2); + EXPECT_GT(static_cast(left.g), 200) + << "the FIRST sub-draw's client range did not reach the draw: " << left; + EXPECT_GT(static_cast(right.g), 200) + << "the SECOND sub-draw's client range did not reach the draw (" << right + << "): an upload that ran once for the whole call instead of once per sub-draw " + "leaves exactly this half unpainted"; + + quad.Release(); + glUseProgram(0); + glDeleteProgram(program); + EXPECT_EQ(FirstGLError(), 0u); + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp new file mode 100644 index 000000000..b13f09a71 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp @@ -0,0 +1,329 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CsoContentAddressingScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - THE CSO CONTENT-ADDRESSING NEGATIVE CONTROL (gate G12). +// +// P2's render-state CSO is content-addressed: the client hashes the 396 pipeline bytes, probes a +// 64-entry cache, memcmps a hash hit and reuses the handle. The whole design is measured against +// a knob that turns that off - kMGPipeBehaviourNoCsoContentAddressing, bit 63 of the runtime +// MOBILEGL_PIPE_PUSH bitmask - so that "push is slower" can be told apart from "the CSO design is +// slower" (P2 brief D.4.5). A measurement knob has one characteristic failure mode: it stops +// steering anything and every later number is quietly taken against a switch that does nothing. +// This file is the entry that cannot let that happen. +// +// WHAT IT ASSERTS, per arm, and why those are the right shapes: +// +// content-addressed (MOBILEGL_PIPE_PUSH=0x7f) +// A Blaze3D blend toggle - enable / draw / disable / draw, N times, which is the workload +// the CsoCache exists for (ARCHITECTURE.md 5.1: the push happens at validate rather than in +// the setter precisely because Blaze3D brackets every batch this way) - visits exactly TWO +// distinct pipeline subsets. So the mint count must stay small and BOUNDED while the bind +// count grows with the draws: csom << csob. +// +// no content addressing (MOBILEGL_PIPE_PUSH=0x800000000000007f) +// Every pipeline-version change mints a fresh CSO and the map is never probed, so mint and +// bind must move together: csom == csob. This is the assertion a dead switch fails - with +// the bit ignored, this arm would report csom << csob just like the other one. +// +// both arms +// The PIXELS must not move. The quad is drawn with alpha 1.0 through +// GL_SRC_ALPHA / GL_ONE_MINUS_SRC_ALPHA, so the blended and unblended draws produce the +// same colour by construction and the readback is the same image in both arms and after +// every toggle. "The counters moved and the picture did not" is the whole claim. +// +// HOW THE COUNTERS ARE READ. MG_Util::PipeStats is internal to the library and this module cannot +// link against it (ScenarioFixture.h explains why: on Android this binary links the SHIPPING +// libMobileGL.so, built -fvisibility=hidden). The library's own summary line is the only channel, +// so each lane sets MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 - one line per +// eglSwapBuffers - and a MOBILEGL_LOG_FILE_PATH of its OWN. The log path has to be private: the +// library opens it fopen(path, "w"), so every process in a lane truncates it, and a whole-file +// read in a shared lane races a neighbour's bring-up. That is the same rule, and the same +// remedy, as PipeVerifyArmingScenario's arming lane. +// +// The window a summary line reports is "since the previous line" (PipeStats::FormatWindowLine), so +// the workload runs inside ONE frame: a swap before it closes the setup window, and the swap after +// it emits a line whose csom / csob cover the toggle loop and nothing else. +// +// WHY IT CAN SKIP. The counters are minted by the client-side tracker (P2 package B), and this +// file is written against the P2 contract commit, before that package lands. Until the tracker +// exists there is no CSO to mint, csom is structurally 0 and an assertion about its ratio to csob +// would be a statement about nothing. The build answers the question rather than a hand-maintained +// list: MG_IntegrationTest/CMakeLists.txt greps every source under MG_Impl/Pipe/ for the two +// counters' names and passes the answer in as MGITEST_PIPE_TRACKER_PRESENT, with a +// CONFIGURE_DEPENDS on that directory and on each file it finds so the answer cannot go stale. +// It is a CONTENT probe, not a filename probe, precisely so that the owning package keeps control +// of its own file layout - it implements the tracker and the cache header-only today, and a glob +// for `Tracker.cpp` would have kept this control skipping forever after that package landed, with +// a reason that had become false. When an emitter lands the arms arm themselves; until then the +// entries are registered, visible and SKIPPED with the reason - never absent, and never green for +// having asserted nothing. + +#include +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // Set by the two CsoContentAddressing. ctest entries and by nothing else; a harness + // marker, never read by the library. Its absence means an ambient entry, where neither + // the stats channel nor a private log path is configured. + constexpr const char* kLaneMarker = "MGITEST_CSO_LANE"; + constexpr const char* kLaneContentAddressed = "content-addressed"; + constexpr const char* kLaneNoContentAddressing = "no-content-addressing"; + + // Toggle pairs per frame. 8 is small enough to keep the frame cheap and large enough that + // "mints stay bounded" and "mints track binds" are different numbers by a wide margin. + constexpr int kTogglePairs = 8; + constexpr int kDrawsPerFrame = kTogglePairs * 2; + // The blend toggle visits two distinct pipeline subsets, so two CSOs. The bound is + // deliberately a little looser than 2: a future chunk-table change could legitimately + // split one of them, and the claim being pinned here is "bounded, not per-draw". + constexpr long long kMaxDistinctCsos = 4; + + constexpr const char* kVS = R"(#version 330 core +in vec2 aPos; +void main() { gl_Position = vec4(aPos, 0.0, 1.0); } +)"; + + constexpr const char* kFS = R"(#version 330 core +out vec4 oColor; +void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + constexpr int kInset = 2; + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + std::string LaneName() { + const char* lane = std::getenv(kLaneMarker); + return lane != nullptr ? std::string(lane) : std::string(); + } + + std::string LibraryLogPath() { + const char* path = std::getenv("MOBILEGL_LOG_FILE_PATH"); + return (path != nullptr && *path != '\0') ? std::string(path) : std::string(); + } + + std::string ReadWholeFile(const std::string& path) { + if (path.empty()) return {}; + std::ifstream file(path, std::ios::binary); + if (!file.good()) return {}; + return std::string((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + } + + // One window's CSO counters, as the library printed them. + struct CsoWindow { + bool found = false; + long long mints = -1; + long long binds = -1; + std::string line; + }; + + // Parses `... cso[csom= csob=] ...` out of the LAST "MGPipe stats:" line in the log. + // The last line, because the window a line reports is "since the previous line" and the + // caller closes the setup window with a swap before the workload. + CsoWindow LastCsoWindow(const std::string& log) { + CsoWindow window; + const std::string marker = "MGPipe stats:"; + std::size_t at = log.rfind(marker); + if (at == std::string::npos) return window; + const std::size_t end = log.find('\n', at); + window.line = log.substr(at, end == std::string::npos ? std::string::npos : end - at); + + const std::string mintKey = "csom="; + const std::string bindKey = "csob="; + const std::size_t mintAt = window.line.find(mintKey); + const std::size_t bindAt = window.line.find(bindKey); + if (mintAt == std::string::npos || bindAt == std::string::npos) return window; + window.mints = std::strtoll(window.line.c_str() + mintAt + mintKey.size(), nullptr, 10); + window.binds = std::strtoll(window.line.c_str() + bindAt + bindKey.size(), nullptr, 10); + window.found = true; + return window; + } + + class CsoContentAddressingScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + m_lane = LaneName(); + std::string error; + m_program = CompileProgram(kVS, kFS, &error); + ASSERT_NE(m_program, 0u) << error; + + const float quad[12] = {-1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f}; + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(quad), quad, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "scene setup left a GL error behind"; + RecordProperty("lane", m_lane.empty() ? "ambient" : m_lane.c_str()); + } + + void TearDown() override { + if (!Ready()) return; + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + } + + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; every caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheLaneIsAssertableHere() { + if (m_lane.empty()) { + GTEST_SKIP() << "runs only in its own lane: the two CsoContentAddressing. ctest entries set " + "MGITEST_CSO_LANE together with the MOBILEGL_PIPE_PUSH bitmask, " + "MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private " + "MOBILEGL_LOG_FILE_PATH. None of that is configured in the ambient " + "entries, and the ambient log is shared, so a read here would race."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so there is no " + "render-state CSO to mint, no cso[] bracket in the summary line and " + "nothing for the content-addressing bit to steer. The entry is " + "registered here anyway so that `ctest -L integration-gpu` names the " + "same tests in the pull build and the push build (gate G2)."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_TRACKER_PRESENT")) { + GTEST_SKIP() << "the CSO counters have no emitter in this build: no source under " + "MobileGL/MG_Impl/Pipe/ names RenderStateCsoMints or " + "RenderStateCsoBinds, so nothing mints or binds a render-state CSO " + "and csom / csob are structurally zero. P2 package B owns the tracker " + "and the CSO cache; this entry arms itself when they land, whatever " + "files that package chooses to put them in."; + return; + } + if (LibraryLogPath().empty()) { + GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's summary " + "line is the only channel this module has for reading PipeStats"; + return; + } + } + + // enable / draw / disable / draw, kTogglePairs times, entirely inside one frame. + // Returns the readback taken at the end of that frame, before the swap. + Image RunBlendToggleFrame() { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_program); + glBindVertexArray(m_vao); + glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO); + for (int i = 0; i < kTogglePairs; ++i) { + glEnable(GL_BLEND); + glDrawArrays(GL_TRIANGLES, 0, 6); + glDisable(GL_BLEND); + glDrawArrays(GL_TRIANGLES, 0, 6); + } + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + std::string m_lane; + GLuint m_program = 0; + GLuint m_vao = 0; + GLuint m_vbo = 0; + }; + + // ONE case per lane, and that is a hard constraint rather than a style choice. + // + // This case READS the library log, and the log is a per-LANE resource: the library opens it + // fopen(path, "w"), so every process in a lane truncates it. A second case in this lane would + // therefore race this one under `ctest -j`, and the shape of the failure is a silent, empty + // read that looks exactly like "the counters were never emitted". Splitting the plumbing + // assertion into its own case would have bought a clearer failure message and paid for it + // with a flake in the thing the message is about. The plumbing is asserted first, with its + // own message, inside this one process instead. + TEST_F(CsoContentAddressingScenario, TheBlendToggleMintsBoundedlyWithContentAddressingAndPerBindWithout) { + if (!Ready()) return; + SkipUnlessTheLaneIsAssertableHere(); + if (IsSkipped()) return; + + Gl().EndFrame(); // close the setup window + const Image first = RunBlendToggleFrame(); + const CsoWindow window = LastCsoWindow(ReadWholeFile(LibraryLogPath())); + // The plumbing first, with its own message, so a counter-ratio failure below can never + // be confused with "the lane never turned the stats channel on". + ASSERT_TRUE(window.found) + << "no 'MGPipe stats:' line carrying cso[csom= csob=] in " << LibraryLogPath() + << ". This IS a push build (the lane checked MGITEST_PIPE_PUSH_BUILD before getting " + "here) and the cso[] bracket is unconditional inside that #if, so it cannot be " + "missing for a build reason: either MOBILEGL_PIPE_STATS / " + "MOBILEGL_PIPE_STATS_PERIOD did not reach the process, or no summary line was " + "emitted at all because nothing reached PipeStats::OnPresent."; + RecordProperty("cso_line", window.line.c_str()); + + // Every draw in the frame changed the pipeline subset, so every draw is a bind. This + // is the denominator both arms are read against; without it, "csom == csob" would also + // be satisfied by a frame in which neither happened at all. + ASSERT_GE(window.binds, static_cast(kDrawsPerFrame)) + << "the toggle frame issued " << kDrawsPerFrame + << " draws whose pipeline subset alternates, so it must have issued at least that many " + "render-state binds. It reported: " + << window.line; + + if (m_lane == kLaneContentAddressed) { + EXPECT_LE(window.mints, kMaxDistinctCsos) + << "with content addressing on, enable/draw/disable/draw x " << kTogglePairs + << " visits two distinct pipeline subsets and must mint a bounded number of CSOs, then " + "reuse them. It reported: " + << window.line; + EXPECT_LT(window.mints, window.binds) + << "with content addressing on the cache must be answering binds it did not mint. " + << window.line; + } else if (m_lane == kLaneNoContentAddressing) { + EXPECT_EQ(window.mints, window.binds) + << "kMGPipeBehaviourNoCsoContentAddressing (bit 63 of MOBILEGL_PIPE_PUSH) must make every " + "bind mint a fresh CSO - the map is never probed and no handle is ever reused. Equal " + "counters are the only reading that proves the bit STEERED anything: if it were " + "ignored, this arm would report the same bounded mint count as the other one. It " + "reported: " + << window.line; + } else { + FAIL() << "unknown " << kLaneMarker << " value '" << m_lane << "'"; + } + + // ... and the picture is the same in both arms and after every toggle. The quad is + // opaque, so the blended and unblended draws agree by construction. + EXPECT_TRUE(RegionIsMostly(first, kInset, first.Width() - kInset, kInset, first.Height() - kInset, + "green", 0.0, "the blend-toggle frame [" + m_lane + "]")); + const Image second = RunBlendToggleFrame(); + EXPECT_TRUE(second == first) + << "the second toggle frame does not match the first: " << second.ByteDiffCount(first) + << " bytes differ. The CSO path must not change what is drawn."; + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp new file mode 100644 index 000000000..d697f3085 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp @@ -0,0 +1,270 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/CtWireScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// P5c ct (MG_Remote/CONTRACT-P5C.md §5): the two control records, end to end over inproc. +// +// applier_reset (§5.1): this process's first validate primes the pipe tracker, and the +// FreshlyPrimed edge is the record's producer (MG_Impl/Pipe/PipeFill.cpp). What the scenario +// asserts is the SERVER sink's own counters - moved by nobody else - plus the pixels, so a +// reset that never crossed is red by the tally and a client that fell through to the driver +// is red by ScenarioFixture's emit-ordinal rule. +// +// object_death (§5.2): a texture and a framebuffer die, the deaths cross, and the slots +// recycle. The picture after recycling is the behavioural half: a stale twin answering for +// the recycled object renders the DEAD object's content (or errors), which is the exact +// shape LiveGenAt's generation check exists to refuse. +// +// RED ONCE for the reset (executed, recorded in the package report): reverting PipeFill's +// FreshlyPrimed arm to the GL-thread direct MGPipeApplierReset() call aborts this whole lane +// with Fatal{RoleViolation, "g_applier"} out of the applier's layer-2 guard. The automated +// half of that control is TheDirectApplierResetCallOnTheGLThreadIsRoleViolation below, and +// the guard's two non-Fatal arms are pinned in PipeWireCodecTest's CtWireFatals suite. + +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitRuntimePeek.h" + +#include +#include + +#if !defined(_WIN32) +#include +#include +#include +#include +#endif + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { +namespace { + + MobileGL::MG_Remote::Server::ServerVerbSink& ServerVerbs() { + return MobileGL::MG_Remote::Server::ServerSessionInstance().Applier().Verbs(); + } + + class CtWireScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + const auto why = SplitRuntimeSkipReason(); + if (!why.empty()) GTEST_SKIP() << why; + } + + // One 4x4 RGBA8 texture whose every texel is `rgba`, uploaded so the object crosses + // (no handle, no record - CONTRACT-P5C.md §5.2: an object that never crossed emits + // nothing at death, and this case needs a real death). + GLuint MakeSolidTexture(const GLubyte rgba[4]) { + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + GLubyte texels[4 * 4 * 4]; + for (int i = 0; i < 4 * 4; ++i) { + for (int c = 0; c < 4; ++c) texels[i * 4 + c] = rgba[c]; + } + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, 4, 4, GL_RGBA, GL_UNSIGNED_BYTE, texels); + return texture; + } + + // The texture's level-0 content, read back through a throwaway FBO, as four bytes. + // The FBO is bound AND deleted here, so it never leaks a framebuffer death into the + // tally a case is watching. + void ReadTextureLevel(GLuint texture, GLubyte out[4]) { + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, out); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + } + }; + + TEST_F(CtWireScenario, ApplierResetCrossesAtThePrimedEdgeInSerialOrder) { + if (!Ready()) return; + // The first verb of the process primes the tracker; the edge is the producer. The + // clear also gives the case its pixels, so a lane that emitted nothing is red twice + // (here and in the fixture's emit-ordinal rule). + glClearColor(0.2f, 0.4f, 0.6f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + EXPECT_GE(ServerVerbs().ApplierResets(), 1u) + << "the FreshlyPrimed edge emitted no applier_reset; the server's g_applier was " + "never reset for this session"; + // The serial sequence is the session's own count (§1: asserted, never dispatched + // on): every record the sink accepted carried the serial it expected, or the counter + // and the accepted tally would disagree. + EXPECT_EQ(ServerVerbs().ExpectedApplierResetSerial(), ServerVerbs().ApplierResets()) + << "an applier_reset record was dropped or replayed on the wire"; + + GLubyte pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "Ct.ApplierReset.error"; + const int expected[4] = {51, 102, 153, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) << "Ct.ApplierReset.pixels"; + } + + TEST_F(CtWireScenario, TextureDeathCrossesAndTheRecycledSlotAnswersTheNewObject) { + if (!Ready()) return; + // The object_death producer is Espryt-side (OnFrontendStateObjectDestroyed, + // CONTRACT-P5C.md §5.2); Magma installs no StateObjectDeathOps (P7), so under + // DirectVulkan there is no death record to watch and the case has nothing to prove. + if (HeadlessGL::Get().BackendName() != "DirectGLES") { + GTEST_SKIP() << "object_death is produced by the DirectGLES death-notice ops; " + "DirectVulkan has none until P7"; + } + const GLubyte red[4] = {255, 0, 0, 255}; + const GLubyte green[4] = {0, 255, 0, 255}; + + // A texture lives and crosses (the upload emits its records). It is also READ BACK + // once before it dies: the read forces the server-side sync that creates the twin - + // and, one level down, resolves the Espryt slot arm that INSTALLS the death-notice + // ops, which no process has before its first twin lookup. The red picture is the + // pre-death control the recycle below is read against. + GLuint texture = MakeSolidTexture(red); + GLubyte before[4]{}; + ReadTextureLevel(texture, before); + const int redExpected[4] = {255, 0, 0, 255}; + for (int i = 0; i < 4; ++i) ASSERT_NEAR(before[i], redExpected[i], 1) << "Ct.TextureDeath.before"; + + // The object dies unbound so the destructor - and the death record - fire at the + // delete, and the tally is read AFTER the EmitAndWait the delete blocked on. + glBindTexture(GL_TEXTURE_2D, 0); + const MobileGL::Uint64 deathsBefore = ServerVerbs().ObjectDeaths(); + glDeleteTextures(1, &texture); + // THE FENCE, the framebuffer case's exactly (MOBILEGL_IPC_BATCH_WAITS, default on): + // object_death is a kCtxObject value-class record, published WITHOUT waiting for its + // own apply, and the server-side tally only moves at apply time. Without a wait + // boundary this read races the apply thread - it passed by timing until P5d round 3's + // clock-free spin changed that timing, then failed 1 in 5. A clear is kCtxVerb and + // still waits, and its wait covers the death. + glClear(GL_COLOR_BUFFER_BIT); + EXPECT_GT(ServerVerbs().ObjectDeaths(), deathsBefore) + << "the texture's death produced no object_death record; the server's twin was " + "never told to let go"; + + // The slot recycles forward: a new texture (the frontend hands the same GL name back + // more often than not, which is exactly the ABA shape) must answer with ITS content, + // not the dead twin's. Red then green, read back as pixels: a stale twin is red. + texture = MakeSolidTexture(green); + GLubyte pixel[4]{}; + ReadTextureLevel(texture, pixel); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &texture); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "Ct.TextureDeath.error"; + const int expected[4] = {0, 255, 0, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) + << "Ct.TextureDeath.pixels - the recycled texture read back the dead twin's content"; + } + + TEST_F(CtWireScenario, FramebufferDeathCrossesAndTheRecycledSlotAnswersTheNewObject) { + if (!Ready()) return; + // Same producer reason as the texture case above: object_death is emitted by the + // DirectGLES death-notice ops; DirectVulkan has none until P7. + if (HeadlessGL::Get().BackendName() != "DirectGLES") { + GTEST_SKIP() << "object_death is produced by the DirectGLES death-notice ops; " + "DirectVulkan has none until P7"; + } + // Framebuffer is the kind object_death EXISTS for: it has no other wire delete + // opcode (CONTRACT-P5C.md §5.2). The renderbuffer goes along so the FBO has storage. + GLuint fbo = 0, renderbuffer = 0; + glGenFramebuffers(1, &fbo); + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)) + << "Ct.FramebufferDeath.setup"; + glClearColor(0.0f, 0.0f, 1.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + GLubyte pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + const int blue[4] = {0, 0, 255, 255}; + for (int i = 0; i < 4; ++i) ASSERT_NEAR(pixel[i], blue[i], 1) << "Ct.FramebufferDeath.first"; + + // Die unbound, framebuffer first so the attachment's own death is a separate record. + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + const MobileGL::Uint64 deathsBefore = ServerVerbs().ObjectDeaths(); + glDeleteFramebuffers(1, &fbo); + // THE FENCE (MOBILEGL_IPC_BATCH_WAITS, default on): object_death is a kCtxObject + // value-class record - published WITHOUT waiting for its own apply (production-safe: + // the in-order ring applies it before any record that recycles the slot). The + // server-side counter this assertion reads only moves at apply time, so it needs a + // wait boundary: a clear is kCtxVerb and still waits, and its wait covers the death. + glClear(GL_COLOR_BUFFER_BIT); + EXPECT_GT(ServerVerbs().ObjectDeaths(), deathsBefore) + << "the framebuffer's death produced no object_death record - and no other " + "opcode can carry it"; + glDeleteRenderbuffers(1, &renderbuffer); + + // Recycle: a new FBO and a new backing store, cleared to a different colour. A stale + // twin answering for the recycled framebuffer renders the blue of the dead one. + glGenFramebuffers(1, &fbo); + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)) + << "Ct.FramebufferDeath.recycle"; + glClearColor(1.0f, 1.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glDeleteFramebuffers(1, &fbo); + glDeleteRenderbuffers(1, &renderbuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "Ct.FramebufferDeath.error"; + const int yellow[4] = {255, 255, 0, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], yellow[i], 1) + << "Ct.FramebufferDeath.pixels - the recycled framebuffer read back the dead twin's content"; + } + +#if !defined(_WIN32) + TEST_F(CtWireScenario, TheDirectApplierResetCallOnTheGLThreadIsRoleViolation) { + if (!Ready()) return; + // A verb in the PARENT, so the fixture's emit-ordinal rule has something to see: the + // death statement below runs only in the child, and a parent that emitted nothing + // is the shape that rule exists to fail. + glClearColor(0.1f, 0.1f, 0.1f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + // THE RED-ONCE FOR §5.1, AUTOMATED. This process has a LIVE inproc session, and the + // GL-thread direct call the applier_reset record replaced is a named Fatal there + // (CONTRACT-P5C.md §5.1 / §6 layer 2) - which is exactly the shape reverting + // PipeFill's FreshlyPrimed arm would take, so the guard is what turns that revert + // red. EXPECT_EXIT re-runs the case in a child process: the child brings up its own + // session (its SetUp is this same fixture's) and aborts at the statement. The two + // NON-Fatal arms - monolith, and a transport with no client session - are the unit + // suite's CtWireFatals.TheDirectApplierResetCallSurvivesMonolithAndAWirelessTransport. + // + // The regex is ".*" because the library's Fatal line goes to its log file, not to + // the stderr a death test matches (ServerLoopEglTest's own EXPECT_EXIT does the + // same); the NAME is asserted from the log below, which the child truncated and + // wrote before it died. + EXPECT_EXIT(MobileGL::MG_Pipe::MGPipeApplierReset(), ::testing::KilledBySignal(SIGABRT), ".*"); + if (const char* logPath = std::getenv("MOBILEGL_LOG_FILE_PATH"); logPath != nullptr) { + std::ifstream in(logPath, std::ios::binary); + std::ostringstream log; + log << in.rdbuf(); + EXPECT_NE(log.str().find("Fatal{RoleViolation, \"g_applier\"}"), std::string::npos) + << "the child aborted, but not with the layer-2 guard's own line:\n" + << log.str(); + } + } +#endif + +} // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp new file mode 100644 index 000000000..acc02e951 --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/F1WireScenario.cpp @@ -0,0 +1,343 @@ +// f1 split-only pixel controls: every result depends on the migrated verb. +#include "../Harness/ScenarioFixture.h" +#include "../Harness/SplitRuntimePeek.h" +#include +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { +namespace { +class F1WireScenario : public ScenarioTest { +protected: + GLuint fbo = 0, texture = 0; + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + const auto why = SplitRuntimeSkipReason(); + if (!why.empty()) GTEST_SKIP() << why; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glDepthMask(GL_TRUE); + glStencilMask(~0u); + } + void TearDown() override { + if (!Ready()) return; + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (fbo) glDeleteFramebuffers(1, &fbo); + if (texture) glDeleteTextures(1, &texture); + ScenarioTest::TearDown(); + } + void Attach(GLenum format, GLenum attachment = GL_COLOR_ATTACHMENT0, int levels = 1) { + glTexStorage2D(GL_TEXTURE_2D, levels, format, 8, 8); + glFramebufferTexture2D(GL_FRAMEBUFFER, attachment, GL_TEXTURE_2D, texture, 0); + if (attachment == GL_DEPTH_STENCIL_ATTACHMENT) { glDrawBuffer(GL_NONE); glReadBuffer(GL_NONE); } + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)) << "F1.setup.framebuffer"; + } +}; +} + +TEST_F(F1WireScenario, ClearBufferfvPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearBufferfv.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA8); + const GLfloat value[4] = {0.25f, 0.5f, 0.75f, 1.0f}; + const auto before = PeekSplitRuntime().emitSeq; + glClearBufferfv(GL_COLOR, 0, value); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearBufferfv.wire"; + GLubyte pixel[4]{}; + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearBufferfv.error"; + const int expected[4] = {64, 128, 191, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) << "F1.ClearBufferfv.pixels"; +} + +TEST_F(F1WireScenario, ClearNamedFramebufferfvPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearNamedFramebufferfv.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA8); + const GLfloat value[4] = {0.25f, 0.5f, 0.75f, 1.0f}; + const auto before = PeekSplitRuntime().emitSeq; + glClearNamedFramebufferfv(fbo, GL_COLOR, 0, value); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearNamedFramebufferfv.wire"; + GLubyte pixel[4]{}; + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearNamedFramebufferfv.error"; + const int expected[4] = {64, 128, 191, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) << "F1.ClearNamedFramebufferfv.pixels"; +} + +TEST_F(F1WireScenario, ClearBufferivPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearBufferiv.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA32I); + const GLint value[4] = {-37, 19, -11, 5}; + const auto before = PeekSplitRuntime().emitSeq; + glClearBufferiv(GL_COLOR, 0, value); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearBufferiv.wire"; + GLint pixel[4]{}; + glReadPixels(2, 3, 1, 1, GL_RGBA_INTEGER, GL_INT, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearBufferiv.error"; + for (int i = 0; i < 4; ++i) EXPECT_EQ(pixel[i], value[i]) << "F1.ClearBufferiv.pixels"; +} + +TEST_F(F1WireScenario, ClearNamedFramebufferivPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearNamedFramebufferiv.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA32I); + const GLint value[4] = {-37, 19, -11, 5}; + const auto before = PeekSplitRuntime().emitSeq; + glClearNamedFramebufferiv(fbo, GL_COLOR, 0, value); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearNamedFramebufferiv.wire"; + GLint pixel[4]{}; + glReadPixels(2, 3, 1, 1, GL_RGBA_INTEGER, GL_INT, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearNamedFramebufferiv.error"; + for (int i = 0; i < 4; ++i) EXPECT_EQ(pixel[i], value[i]) << "F1.ClearNamedFramebufferiv.pixels"; +} + +TEST_F(F1WireScenario, ClearBufferuivPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearBufferuiv.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA32UI); + const GLuint value[4] = {37, 19, 11, 5}; + const auto before = PeekSplitRuntime().emitSeq; + glClearBufferuiv(GL_COLOR, 0, value); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearBufferuiv.wire"; + GLuint pixel[4]{}; + glReadPixels(2, 3, 1, 1, GL_RGBA_INTEGER, GL_UNSIGNED_INT, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearBufferuiv.error"; + for (int i = 0; i < 4; ++i) EXPECT_EQ(pixel[i], value[i]) << "F1.ClearBufferuiv.pixels"; +} + +TEST_F(F1WireScenario, ClearNamedFramebufferuivPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearNamedFramebufferuiv.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA32UI); + const GLuint value[4] = {37, 19, 11, 5}; + const auto before = PeekSplitRuntime().emitSeq; + glClearNamedFramebufferuiv(fbo, GL_COLOR, 0, value); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearNamedFramebufferuiv.wire"; + GLuint pixel[4]{}; + glReadPixels(2, 3, 1, 1, GL_RGBA_INTEGER, GL_UNSIGNED_INT, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearNamedFramebufferuiv.error"; + for (int i = 0; i < 4; ++i) EXPECT_EQ(pixel[i], value[i]) << "F1.ClearNamedFramebufferuiv.pixels"; +} + +TEST_F(F1WireScenario, ClearBufferfiPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearBufferfi.pixels fails. + if (!Ready()) return; + Attach(GL_DEPTH24_STENCIL8, GL_DEPTH_STENCIL_ATTACHMENT); + const auto before = PeekSplitRuntime().emitSeq; + glClearBufferfi(GL_DEPTH_STENCIL, 0, 0.375f, 91); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearBufferfi.wire"; + GLuint pixel = 0; + glReadPixels(2, 3, 1, 1, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, &pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearBufferfi.error"; + EXPECT_EQ(pixel & 255u, 91u) << "F1.ClearBufferfi.pixels"; + EXPECT_NEAR(double(pixel >> 8) / 16777215.0, 0.375, 0.00001) << "F1.ClearBufferfi.pixels"; +} + +TEST_F(F1WireScenario, ClearNamedFramebufferfiPixels) { + // Red once (executed, reverted): zero the clear record values; F1.ClearNamedFramebufferfi.pixels fails. + if (!Ready()) return; + Attach(GL_DEPTH24_STENCIL8, GL_DEPTH_STENCIL_ATTACHMENT); + const auto before = PeekSplitRuntime().emitSeq; + glClearNamedFramebufferfi(fbo, GL_DEPTH_STENCIL, 0, 0.375f, 91); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.ClearNamedFramebufferfi.wire"; + GLuint pixel = 0; + glReadPixels(2, 3, 1, 1, GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, &pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.ClearNamedFramebufferfi.error"; + EXPECT_EQ(pixel & 255u, 91u) << "F1.ClearNamedFramebufferfi.pixels"; + EXPECT_NEAR(double(pixel >> 8) / 16777215.0, 0.375, 0.00001) << "F1.ClearNamedFramebufferfi.pixels"; +} + +TEST_F(F1WireScenario, CopyTexImage2DPixels) { + // Red once (executed, reverted): omit the copy sink call; F1.CopyTexImage2D.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA8); + glClearColor(0.25f, 0.5f, 0.75f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + GLuint destination = 0; + glGenTextures(1, &destination); + glBindTexture(GL_TEXTURE_2D, destination); + + const auto before = PeekSplitRuntime().emitSeq; + glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 2, 3, 4, 4, 0); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.CopyTexImage2D.wire"; + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, destination, 0); + GLubyte pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.CopyTexImage2D.error"; + const int expected[4] = {64, 128, 191, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) << "F1.CopyTexImage2D.pixels"; + glDeleteTextures(1, &destination); +} + +TEST_F(F1WireScenario, CopyTexSubImage2DPixels) { + // Red once (executed, reverted): omit the copy sink call; F1.CopyTexSubImage2D.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA8); + glClearColor(0.25f, 0.5f, 0.75f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + GLuint destination = 0; + glGenTextures(1, &destination); + glBindTexture(GL_TEXTURE_2D, destination); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 4, 4); + const auto before = PeekSplitRuntime().emitSeq; + glCopyTexSubImage2D(GL_TEXTURE_2D, 0, 1, 1, 2, 3, 2, 2); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.CopyTexSubImage2D.wire"; + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, destination, 0); + GLubyte pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.CopyTexSubImage2D.error"; + const int expected[4] = {64, 128, 191, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) << "F1.CopyTexSubImage2D.pixels"; + glDeleteTextures(1, &destination); +} + +TEST_F(F1WireScenario, GenerateMipmapPixels) { + // Red once (executed, reverted): omit the mipmap sink call; F1.GenerateMipmap.pixels fails. + if (!Ready()) return; + Attach(GL_RGBA8, GL_COLOR_ATTACHMENT0, 4); + glClearColor(0.25f, 0.5f, 0.75f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + const auto before = PeekSplitRuntime().emitSeq; + glGenerateMipmap(GL_TEXTURE_2D); + ASSERT_GT(PeekSplitRuntime().emitSeq, before) << "F1.GenerateMipmap.wire"; + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 2); + GLubyte pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "F1.GenerateMipmap.error"; + const int expected[4] = {64, 128, 191, 255}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 1) << "F1.GenerateMipmap.pixels"; +} +TEST_F(F1WireScenario, GenerateMipmapPackedFloatPixels) { + if (!Ready()) return; + // Only level zero exists initially. Generating the special-format chain must use the + // shape published by the client, and level two must contain the generated GPU pixels. + std::array source{}; + for (size_t i = 0; i < source.size(); i += 3) { + source[i] = 0.25f; source[i + 1] = 0.5f; source[i + 2] = 0.75f; + } + glTexImage2D(GL_TEXTURE_2D, 0, GL_R11F_G11F_B10F, 8, 8, 0, GL_RGB, GL_FLOAT, source.data()); + const auto before = PeekSplitRuntime().emitSeq; + glGenerateMipmap(GL_TEXTURE_2D); + ASSERT_GT(PeekSplitRuntime().emitSeq, before); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 2); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + GLfloat pixel[4]{}; + glReadPixels(1, 1, 1, 1, GL_RGBA, GL_FLOAT, pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + const GLfloat expected[4] = {0.25f, 0.5f, 0.75f, 1.0f}; + for (int i = 0; i < 4; ++i) EXPECT_NEAR(pixel[i], expected[i], 0.01f); +} + +TEST_F(F1WireScenario, GenerateMipmapDepthPixels) { + if (!Ready()) return; + std::array source{}; + source.fill(0.375f); + glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32F, 8, 8, 0, + GL_DEPTH_COMPONENT, GL_FLOAT, source.data()); + const auto before = PeekSplitRuntime().emitSeq; + glGenerateMipmap(GL_TEXTURE_2D); + ASSERT_GT(PeekSplitRuntime().emitSeq, before); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, texture, 2); + glDrawBuffer(GL_NONE); + glReadBuffer(GL_NONE); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + GLfloat pixel = 0; + glReadPixels(1, 1, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &pixel); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + EXPECT_NEAR(pixel, 0.375f, 0.00001f); +} + +TEST_F(F1WireScenario, NamedBlitPreservesBindingsAndRestoresNextVerbsPixels) { + if (!Ready()) return; + Attach(GL_RGBA8); + glClearColor(1, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + GLuint fbos[3]{}, textures[3]{}; + glGenFramebuffers(3, fbos); + glGenTextures(3, textures); + for (int i = 0; i < 3; ++i) { + glBindFramebuffer(GL_FRAMEBUFFER, fbos[i]); + glBindTexture(GL_TEXTURE_2D, textures[i]); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, 8, 8); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textures[i], 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glClearColor(0, i == 1 ? 1 : 0, 1, 1); + glClear(GL_COLOR_BUFFER_BIT); + } + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbos[1]); + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbos[2]); + const auto before = PeekSplitRuntime().emitSeq; + glBlitNamedFramebuffer(fbo, fbos[0], 0, 0, 8, 8, 0, 0, 8, 8, GL_COLOR_BUFFER_BIT, GL_NEAREST); + EXPECT_GT(PeekSplitRuntime().emitSeq, before); + GLint read = 0, draw = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw); + EXPECT_EQ(read, GLint(fbos[1])); + EXPECT_EQ(draw, GLint(fbos[2])); + + // No intervening bind: this must clear the restored draw FBO, not the DSA destination. + glClearColor(1, 0, 1, 1); + glClear(GL_COLOR_BUFFER_BIT); + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbos[2]); + std::array pixel{}; + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel.data()); + EXPECT_EQ(pixel, (std::array{255, 0, 255, 255})) << "named blit restored draw before clear"; + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbos[0]); + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel.data()); + EXPECT_EQ(pixel, (std::array{255, 0, 0, 255})) << "unbound named blit copied source"; + + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbos[1]); + glBlitFramebuffer(0, 0, 8, 8, 0, 0, 8, 8, GL_COLOR_BUFFER_BIT, GL_NEAREST); + glBindFramebuffer(GL_READ_FRAMEBUFFER, fbos[2]); + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel.data()); + EXPECT_EQ(pixel, (std::array{0, 255, 255, 255})) << "ordinary blit follows restored bindings"; + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glDeleteFramebuffers(3, fbos); + glDeleteTextures(3, textures); +} + +TEST_F(F1WireScenario, NamedBlitDefaultEndpointPixels) { + if (!Ready()) return; + Attach(GL_RGBA8); + glClearColor(1, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glClearColor(0, 0, 1, 1); + glClear(GL_COLOR_BUFFER_BIT); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glBlitNamedFramebuffer(fbo, 0, 0, 0, 8, 8, 0, 0, 8, 8, GL_COLOR_BUFFER_BIT, GL_NEAREST); + GLint read = 0, draw = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw); + EXPECT_EQ(read, GLint(fbo)); + EXPECT_EQ(draw, GLint(fbo)); + glBindFramebuffer(GL_READ_FRAMEBUFFER, 0); + std::array pixel{}; + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel.data()); + EXPECT_EQ(pixel, (std::array{255, 0, 0, 255})) << "default draw endpoint"; + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glClearColor(0, 1, 0, 1); + glClear(GL_COLOR_BUFFER_BIT); + glBlitNamedFramebuffer(0, fbo, 0, 0, 8, 8, 0, 0, 8, 8, GL_COLOR_BUFFER_BIT, GL_NEAREST); + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &read); + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &draw); + EXPECT_EQ(read, GLint(fbo)); + EXPECT_EQ(draw, GLint(fbo)); + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel.data()); + EXPECT_EQ(pixel, (std::array{255, 0, 0, 255})) << "default read endpoint"; + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); +} +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp new file mode 100644 index 000000000..e066b92de --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp @@ -0,0 +1,2286 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/HandleRecycleScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - THE HANDLE ABA (gate G8): a frontend object that dies and is replaced at the same +// heap address must not inherit the dead object's backend twin, its vertex-input state, its +// buffer contents, or its draw memo. +// +// P3a ADDS TWO WINDOWS to the four P2 wrote, because it re-keys two more object classes. The +// BUFFER (ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents) is the resource_* +// family's: a store that dies and is replaced at the same slot must not hand the replacement's +// draw the dead store's bytes. The two-buffer VERTEX SET +// (AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexBufferSet) is +// set_vertex_buffers': the per-binding buffer identities are a record of their own, separate +// from the elements blob, and a recycled VAO must not inherit its predecessor's. +// +// WHY THIS EXISTS. Every backend memo in the tree is keyed, today, on some property of a LIVE +// frontend object: a raw `void*` owner pointer (DirectGLES' StateBackendObjectRegistry and its +// three TwinLookupMemos), a `GetLifetimeId()` (DirectVulkan's VertexInputStateFactory::ComputeHash +// and VaoDrawMemo::vaoLifetimeId), or a weak_ptr expiry test. Track H replaces all of them with an +// {slot, gen} handle. The question this scenario asks is the only one that matters about that +// change: does the NEW key actually stop the aliasing the OLD key stopped? A re-key that quietly +// dropped a guard would produce pixels from a dead object's GPU resources, and there is no other +// gate in this tree that can see it - SSIM over a 40-trace corpus cannot, because no fixture +// destroys and immediately re-creates an object with a byte-identical configuration. +// +// HOW THE ABA IS BUILT, through public GL only: +// 1. an object is created, USED IN A DRAW, and used again for a few frames, so that every +// per-object memo in both backends is armed against it; +// 2. it is unbound (so the frontend's last SharedPtr drops - a still-bound object keeps living, +// TextureState.cpp) and deleted; +// 3. a replacement is created IMMEDIATELY, with a byte-identical configuration, so that a +// content hash over the configuration matches the dead object's; +// 4. the replacement is given DIFFERENT CONTENTS - a different vertex buffer, different texels, +// a different attachment; +// 5. one draw, one readback. The pixels must come from the replacement. +// +// The public-GL proxy for "the allocator repeated itself" is the GL NAME: MobileGL's name +// allocators hand a deleted name straight back, so `TheReproducerRecyclesEveryName` asserts the +// recycle happened and every other case asserts on the name it got. When a name is NOT recycled +// the case SKIPS with that reason rather than passing - the shape +// MG_Test/State/ObjectLifetimeIdTest.cpp already uses for exactly this ("inconclusive, not +// proven"). +// +// WHAT THE NAME PROXY DOES NOT BUY, MEASURED RATHER THAN ASSUMED. The name comes back; the C++ +// HEAP BLOCK does not. A VertexArrayObject is 3920 bytes - past glibc's tcache - so its chunk goes +// to the unsorted bin and is split by the very next allocation the replacement path makes; four +// create/delete cycles in one run of this file produced four distinct addresses about a mebibyte +// apart, and the same is true of the BufferObject. An earlier revision of this file left the +// AbaControl arm's collision to that allocator, and the consequence was the failure mode this file +// exists to prevent, in its most literal form: with nothing colliding, the replacement inherited +// nothing, the arm asserted stale pixels, saw fresh ones, and went RED in an always-on +// integration-gpu lane while every guard it was supposed to be defeating was still standing. +// +// So the AbaControl arm no longer asks the allocator for the collision - MOBILEGL_PIPE_HANDLE_ABA_CONTROL +// manufactures it, by replacing the object identity in each key with a constant (see +// MagmaPipeArms.h's MagmaPipeAbaControlDefeatsIdentity). That is the strongest form of "the +// allocator handed the block back", it is deterministic, and - the reason it matters - it defeats +// the {slot, gen} GENERATION as well as the retired lifetime id, so the control covers the key P2 +// actually ships instead of only the one it replaced. +// +// AND THE ABA HAPPENS INSIDE ONE FRAME, which is not a detail. The only backend structure that can +// hand a draw a dead object's GPU slice is VulkanRenderer::ResolvedVertexBindings, and it refuses +// to be trusted across a frame boundary by design ("NO cross-frame trust"). Every other memo the +// recycle can poison holds LAYOUT, which is byte-identical between the two objects by construction +// and so cannot be seen in pixels. A reproducer that puts a frame boundary between the arming draw +// and the recycled draw therefore cannot produce wrong pixels no matter how completely the keys +// collide - it would be asserting a fact about the frame gate, not about identity. +// +// THREE ARMS, ALL ALWAYS ON (P2 brief D18). The arm is named by MGITEST_HANDLE_ARM, which is a +// HARNESS marker - the library never reads it - and the CMake wiring registers one lane per arm: +// +// Handles MOBILEGL_PIPE_PUSH default (Track H bits set), MOBILEGL_PIPE_LEGACY_MEMOS=0. +// The {slot, gen} key is the only key in the process. Expects correct pixels. +// Legacy MOBILEGL_PIPE_PUSH=0. Today's lifetimeId + weak_ptr guards. Expects correct +// pixels - they work, which is the point: the re-key is not fixing a live bug, it +// is replacing a guard, and the replacement has to be at least as strong. +// AbaControl MOBILEGL_PIPE_HANDLE_ABA_CONTROL=1, on TWO lanes: one with MOBILEGL_PIPE_PUSH=0 +// (the pre-handle arm, D18's lane verbatim) and one on the handle arm +// (MOBILEGL_PIPE_LEGACY_MEMOS=0). The knob defeats the object-identity half of +// every vertex-input memo key on whichever arm is running - the pre-handle +// (address, lifetime id) pair and the handle arm's {slot, gen} generation - so both +// lanes expect the CORRUPTION. Two lanes rather than one because the guard P2 SHIPS +// is the generation: a control that only defeated the retired guards would be green +// forever without saying anything about the re-key, which is exactly how this arm +// went vacuous once packages C and D landed. +// +// WHY AN ARM CAN SKIP, AND WHY THAT IS NOT A HOLE. Two of the three arms assert something that +// only EXISTS once another P2 package has landed: `Handles` needs the backend's {slot, gen} arm +// (packages C and D) and `AbaControl` needs the knob's consumer (package D). This file is written +// and merged FIRST, against the P2 contract commit, so that the AbaControl red is recorded before +// either backend is touched. Until then those arms have nothing to assert, and the honest report +// for that is a SKIP that names what is missing - never a silently-deleted registration and never +// a green that means "the thing I test does not exist yet". +// +// The skip is decided by the BUILD, not by a hand-maintained list: MG_IntegrationTest/CMakeLists.txt +// greps the backend sources for the subsystem constant and for the knob's name and passes the +// answer in as MGITEST_HANDLE_REKEY_ / MGITEST_HANDLE_ABA_IMPLEMENTED, with a +// CMAKE_CONFIGURE_DEPENDS on those files so the answer cannot go stale. When C and D land, the +// arms arm themselves. +// +// Those two markers are a statement about the SOURCE TREE, and they are set only in a push build, +// because that is the only build in which the thing they name is compiled: the {slot, gen} re-key +// and Features.PipeHandleAbaControl are both `#if MOBILEGL_PIPE_PUSH`. In a pull build the two +// push arms therefore skip on MGITEST_PIPE_PUSH_BUILD before they ever look at a per-arm marker - +// otherwise, once C and D landed, the pull build would run AbaControl against guards that are +// still in force (a hard red on `ctest -L integration-gpu`, which G2 requires green in BOTH +// builds) and Handles against a library with no re-key in it (a green that asserts nothing). + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/PipeSlotPeek.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // ---- the arm ------------------------------------------------------------------- + + enum class Arm { + Handles, // the {slot, gen} key is the only key + Legacy, // today's lifetimeId + weak_ptr guards + AbaControl // the guards deliberately defeated; the corruption is the assertion + }; + + // Set by the three HandleRecycle. ctest entries and by NOTHING else. It is a harness + // variable, not a library knob (hence the MGITEST_ prefix): the library never reads it. + // Its absence means "this process is one of the ~400 ambient entries", where the arm is + // undefined - MOBILEGL_PIPE_PUSH is at its build default there, which is neither the + // Legacy arm nor the Handles arm - so the cases skip rather than assert something the + // lane did not configure. Same shape, and the same reason, as + // PipeVerifyArmingScenario's MGITEST_PIPE_ARMING_LANE. + constexpr const char* kArmMarker = "MGITEST_HANDLE_ARM"; + + Arm CurrentArm() { + const char* name = std::getenv(kArmMarker); + if (name == nullptr) return Arm::Legacy; + if (std::strcmp(name, "handles") == 0) return Arm::Handles; + if (std::strcmp(name, "aba") == 0) return Arm::AbaControl; + return Arm::Legacy; + } + + // Whether the lane named an arm this file knows. A value that is set but unrecognised is a + // FAILURE (SetUp below), never a quiet fall-through to Legacy: a typo in a lane's + // MGITEST_HANDLE_ARM would otherwise downgrade that lane's Handles or AbaControl assertion + // to the Legacy one, which passes - a lane reporting green for an arm it never ran. Same + // shape as CsoContentAddressingScenario's FAIL() on an unknown MGITEST_CSO_LANE. + bool ArmNameIsRecognised() { + const char* name = std::getenv(kArmMarker); + return name == nullptr || std::strcmp(name, "handles") == 0 || + std::strcmp(name, "legacy") == 0 || std::strcmp(name, "aba") == 0; + } + + bool RunningInAHandleRecycleLane() { return std::getenv(kArmMarker) != nullptr; } + + // Does THIS LANE run with a live client slot allocator behind it - i.e. did it pin a + // non-zero MOBILEGL_PIPE_PUSH? + // + // The leak cases below need this and not the arm (review F-m4). The arm says which key a + // pixel assertion is about; the leak assertion is not about a key at all, it is about the + // allocator, and "the allocator has slots to leak" is exactly "the mask is not zero". The + // two questions almost coincide - DirectGLES/DirectVulkan.HandleRecycle.Legacy. and + // DirectVulkan.HandleRecycle.AbaControl. do pin MOBILEGL_PIPE_PUSH=0 - but + // DirectVulkan.HandleRecycle.AbaControlHandles. pins the shipping 0x1fff WITH a live + // allocator, and gating on `arm == Handles` declined it while telling the reader the + // lane had no allocator, which was false. Reading the lane's own pin covers all three. + // + // The ENVIRONMENT is the right place to read it from and the library's config is not: + // MG_Config is inside the library, this module links the shipping .so on Android, and the + // pin is the LANE's statement about what it configured. An entry that pinned nothing (the + // ambient ones) is not a lane and answers false - the build default may well be non-zero + // there, but an ambient entry configured no arm, no allocator expectation and no private + // log, which is the reason the whole file declines them. + bool LanePinnedALiveAllocator() { + const char* mask = std::getenv("MOBILEGL_PIPE_PUSH"); + if (mask == nullptr || mask[0] == '\0') return false; + // strtoull handles the 0x form every lane spells it in, and a value this module + // cannot parse is treated as "no pin" rather than as a non-zero mask. + char* end = nullptr; + const unsigned long long value = std::strtoull(mask, &end, 0); + return end != nullptr && *end == '\0' && value != 0ull; + } + + // ---- which of the allocator's TWO spaces a leak case measures -------------------- + // + // Every kind but ShaderCso has one space. ShaderCso has two: the ordinary program slots, + // and the reserved high band the program-pipeline COMPOSITES are minted out of through + // the allocator's one door, AllocateComposite (D-H7). c0b split their high-water marks + // (contract-v2.md 4.3) precisely so that a leak case can be written about either, and + // the composite's case has to read the BAND's - a band slot that never comes back moves + // neither of the ordinary numbers, which is the review's F-M4: the case would have + // reported green having never looked at the thing it exists for. + // + // Stated at every call site rather than defaulted, for PipeSlotPeek's `no default:` + // reason: a new leak case must say which space it is about, because the wrong answer is + // a green that asserts nothing rather than a compile error. + enum class SlotSpace { + Ordinary, + CompositeBand, + }; + + const char* SpaceSuffix(SlotSpace space) { + return space == SlotSpace::CompositeBand ? " [composite band]" : ""; + } + + bool ReadSpaceLiveCount(PipeSlotKind kind, SlotSpace space, unsigned* out) { + return space == SlotSpace::CompositeBand ? MGITest::PeekPipeCompositeSlotLiveCount(out) + : MGITest::PeekPipeSlotLiveCount(kind, out); + } + + bool ReadSpaceHighWater(PipeSlotKind kind, SlotSpace space, unsigned* out) { + return space == SlotSpace::CompositeBand ? MGITest::PeekPipeCompositeSlotHighWater(out) + : MGITest::PeekPipeSlotHighWater(kind, out); + } + + // The value a space's high-water mark has when NOTHING of it was ever handed out: 0 for + // the ordinary space, and the band's BASE for the band, because CompositeHighWater() is + // an absolute slot number. Reading this wrong is what would turn the band's "nothing was + // ever minted" skip into a silent pass on a tree that mints composites. + bool ReadSpaceHighWaterFloor(SlotSpace space, unsigned* out) { + if (space != SlotSpace::CompositeBand) { + *out = 0; + return true; + } + return MGITest::PeekPipeCompositeSlotBandBase(out); + } + + const char* ArmName(Arm arm) { + switch (arm) { + case Arm::Handles: return "Handles"; + case Arm::AbaControl: return "AbaControl"; + default: return "Legacy"; + } + } + + // A build-time marker set by MG_IntegrationTest/CMakeLists.txt. "1" means the thing it + // names is present in the sources this binary was built from. + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + + // Whichever of the two backend re-keys applies to the process this binary is running as. + bool ThisBackendsRekeyHasLanded() { + const std::string& backend = HeadlessGL::Get().BackendName(); + if (backend == "DirectVulkan") return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_DirectVulkan"); + return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_DirectGLES"); + } + + // The SAME question for the BUFFER, and it is a different question. The marker above + // answers "is this backend's VERTEX-INPUT memo keyed on {slot, gen}", which P2 landed; + // a buffer only travels as a handle once the resource_* family does (P3a for Espryt, + // P7 for Magma), and until then a buffer's backend twin is still resolved from the + // frontend object. A buffer case that read the P2 marker would therefore report the + // Handles arm as armed on a tree where nothing about a buffer is keyed on a handle - + // green for a re-key that does not exist, which is the one outcome this file exists to + // prevent. Set by MG_IntegrationTest/CMakeLists.txt from a content probe for + // MGPipeResourceOps under each backend's own directory. + bool ThisBackendsResourceRekeyHasLanded() { + const std::string& backend = HeadlessGL::Get().BackendName(); + if (backend == "DirectVulkan") { + return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_RESOURCES_DirectVulkan"); + } + return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_RESOURCES_DirectGLES"); + } + + // P4a's version of the SAME question, and it is a THIRD question rather than a rewording + // of either above. The P2 marker answers "is this backend's VERTEX-INPUT memo keyed on + // {slot, gen}"; the P3a one answers it for the BUFFER. P4a re-keys six more object classes + // - texture, renderbuffer, framebuffer, sampler CSO, sampler view and shader CSO - and + // their four subsystem bits (kMGPipeSubsystem{Framebuffer,TextureResources,Samplers, + // Programs}, MGPipe.h) are what a backend has to name to honour MOBILEGL_PIPE_PUSH's + // default mask. A P4a case that read either older marker would report the Handles arm as + // armed on a tree where nothing about a texture is keyed on a handle - green for a re-key + // that does not exist, which is the one outcome this file exists to prevent. Set by + // MG_IntegrationTest/CMakeLists.txt from a content probe over each backend's own + // directory, exactly like its two predecessors. + bool ThisBackendsObjectRekeyHasLanded() { + const std::string& backend = HeadlessGL::Get().BackendName(); + if (backend == "DirectVulkan") { + return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_OBJECTS_DirectVulkan"); + } + return BuildMarkerIsSet("MGITEST_HANDLE_REKEY_OBJECTS_DirectGLES"); + } + + // "Does MOBILEGL_PIPE_HANDLE_ABA_CONTROL steer THIS backend's P4a OBJECT keys?" - the + // question that decides whether the AbaControl arm of a P4a case expects the corruption + // or the correct pixels, and it is deliberately narrow. + // + // WHY IT IS NOT THE EXISTING MGITEST_HANDLE_ABA_IMPLEMENTED, and this is the P4a finding + // the file records rather than works around. That marker says "some DirectVulkan source + // reads Features.PipeHandleAbaControl", and today exactly one does: + // MagmaPipeArms.h's MagmaPipeAbaControlDefeatsIdentity, whose consumers are Magma's + // VERTEX-INPUT keys. Magma mints {slot, gen} for two kinds only - VertexElementsCso and + // Buffer (MagmaPipeIdentityTables) - so there is no texture, framebuffer, sampler, view or + // program key on that backend for the knob to defeat, and P4a does not add one: Magma's + // object paths are P7 (BRIEF-P4A.md D-Q), and MG_Backend/DirectVulkan/** is untouched in + // P4a apart from MagmaPipeArms.h's own statement of this fact. On DirectGLES the knob has + // no consumer at all. + // + // So on this tree the six P4a cases below run their AbaControl arm with the knob INERT. + // The honest report for that is the arm asserting the correct pixels and SAYING that it is + // not controlling anything here - never a lane that expects a corruption nothing can + // produce, which would be a hard red on an always-on integration-gpu lane, which is + // exactly the failure this file's header records having had once. The moment a backend + // grows a Features.PipeHandleAbaControl consumer over its P4a object slot tables (one `if` + // in GetOrCreate / FindByHandle, the way MagmaPipeClaimSlotMemos is Magma's for vertex + // input), the probe finds it and every one of the six flips to expecting the corruption. + bool ObjectAbaControlIsWiredHere() { + const std::string& backend = HeadlessGL::Get().BackendName(); + const bool knob = backend == "DirectVulkan" + ? BuildMarkerIsSet("MGITEST_HANDLE_ABA_OBJECTS_DirectVulkan") + : BuildMarkerIsSet("MGITEST_HANDLE_ABA_OBJECTS_DirectGLES"); + // Both halves, because either alone is a lie: a knob consumer with no object re-key + // has nothing to defeat, and an object re-key with no knob consumer cannot be defeated. + return knob && ThisBackendsObjectRekeyHasLanded(); + } + + // ---- the scene ----------------------------------------------------------------- + + constexpr const char* kColorVS = R"(#version 330 core +in vec2 aPos; +in vec3 aColor; +out vec3 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + + constexpr const char* kColorFS = R"(#version 330 core +in vec3 vColor; +out vec4 oColor; +void main() { oColor = vec4(vColor, 1.0); } +)"; + + constexpr const char* kSampleVS = 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* kSampleFS = R"(#version 330 core +in vec2 vUv; +uniform sampler2D uTex; +out vec4 oColor; +void main() { oColor = texture(uTex, vUv); } +)"; + + struct Vertex { + float x, y; + float r, g, b; + }; + + // A full-viewport quad in one colour. Both buffers are the SAME SIZE and the SAME + // LAYOUT: only the colour bytes differ, which is what makes a content hash over the + // vertex-input CONFIGURATION identical between them. + std::vector Quad(float r, float g, float b) { + return { + {-1.0f, -1.0f, r, g, b}, {1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, + {-1.0f, -1.0f, r, g, b}, {1.0f, 1.0f, r, g, b}, {-1.0f, 1.0f, r, g, b}, + }; + } + + constexpr int kVertexCount = 6; + // Enough consecutive drawing frames that every per-object memo in both backends is armed + // against the first object before it is destroyed. + constexpr int kWarmupFrames = 3; + // How far inside the viewport the whole-region check starts. The quad covers everything, + // so the inset is only about primitive edges on the outermost pixel row/column. + constexpr int kInset = 2; + + void ExpectWholeViewportIs(const Image& image, const char* expected, const std::string& when) { + EXPECT_TRUE(RegionIsMostly(image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, + expected, 0.0, when)); + } + + // The one thing the whole file turns on: did the pixels come from the REPLACEMENT + // (`fresh`) or from the object that died (`stale`)? The arm decides which is the pass. + void ExpectPixelsFor(Arm arm, bool armExpectsCorruption, const Image& image, const char* fresh, + const char* stale, const std::string& when) { + // Say which of the two was actually observed, on EVERY arm and whether or not the case + // passes. The arm's expectation is only half the evidence, and a reader of the CI log + // should not have to infer the other half from the exit status - least of all for a + // control whose whole claim is "the corruption is still reproducible here". + const bool sawStale = static_cast(RegionIsMostly( + image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, stale, 0.0, when)); + const bool sawFresh = static_cast(RegionIsMostly( + image, kInset, image.Width() - kInset, kInset, image.Height() - kInset, fresh, 0.0, when)); + const bool expectsStale = arm == Arm::AbaControl && armExpectsCorruption; + std::cout << "[ HandleRecycle ] arm=" << ArmName(arm) << " expected=" + << (expectsStale ? "STALE" : "FRESH") << " observed=" + << (sawStale ? "STALE" : (sawFresh ? "FRESH" : "NEITHER")) << " (stale=" << stale + << ", fresh=" << fresh << ") - " << when << std::endl; + if (expectsStale) { + // The corruption IS the assertion. If this ever goes green-by-being-correct the + // reproducer has stopped reproducing and the other two arms prove nothing. + ExpectWholeViewportIs(image, stale, when + " [AbaControl expects the STALE object's pixels: " + "the identity half of every key is deliberately " + "defeated]"); + return; + } + ExpectWholeViewportIs(image, fresh, + when + " [" + ArmName(arm) + " expects the replacement's pixels]"); + } + + class HandleRecycleScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + if (!ArmNameIsRecognised()) { + const char* raw = std::getenv(kArmMarker); + FAIL() << "unknown " << kArmMarker << " value '" << (raw != nullptr ? raw : "") + << "': the arms are handles / legacy / aba. Reading an unrecognised name " + "as Legacy would make this lane assert the pre-re-key guards while " + "claiming to test something else, and it would pass."; + } + m_arm = CurrentArm(); + std::string error; + m_colorProgram = CompileProgram(kColorVS, kColorFS, &error); + ASSERT_NE(m_colorProgram, 0u) << error; + m_sampleProgram = CompileProgram(kSampleVS, kSampleFS, &error); + ASSERT_NE(m_sampleProgram, 0u) << error; + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "program setup left a GL error behind"; + RecordProperty("arm", ArmName(m_arm)); + } + + void TearDown() override { + if (!Ready()) return; + if (m_colorProgram != 0) glDeleteProgram(m_colorProgram); + if (m_sampleProgram != 0) glDeleteProgram(m_sampleProgram); + } + + // Skips the case when the arm it is running under has nothing to assert on THIS tree. + // 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 SkipUnlessTheArmIsAssertableHere() { + if (!RunningInAHandleRecycleLane()) { + GTEST_SKIP() << "runs only in its own lane: the three HandleRecycle. ctest entries set " + "MGITEST_HANDLE_ARM (handles / legacy / aba) together with the " + "MOBILEGL_PIPE_PUSH and MOBILEGL_PIPE_LEGACY_MEMOS values that arm means. " + "The ambient entries configure none of that, so there is nothing here to " + "assert."; + } + // Both push arms are compiled only under MOBILEGL_PIPE_PUSH, so in a pull build + // neither has anything to say whatever the source tree contains. This check comes + // BEFORE the per-arm markers deliberately: those answer "does the source tree + // implement it", which stops being a statement about this library the moment the + // library is the pull one. Without it, a pull build would run the Handles arm + // against a library with no {slot, gen} key (a green asserting nothing) and the + // AbaControl arm against one whose guards are still in force (a hard red on + // `ctest -L integration-gpu`, which G2 requires green in BOTH builds). + // MG_IntegrationTest/CMakeLists.txt already withholds the markers in a pull build; + // this is the second lock, so a hand-forced environment cannot arm them either. + if (m_arm != Arm::Legacy && !BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "the " << ArmName(m_arm) + << " arm needs a library built with MOBILEGL_PIPE_PUSH, and this one " + "was not: the {slot, gen} re-key and Features.PipeHandleAbaControl " + "are both #if MOBILEGL_PIPE_PUSH (Config.h, ConfigLoader.cpp), so " + "there is nothing here for either arm to assert against. The lane " + "stays registered so that `ctest -L integration-gpu` names the same " + "tests in the pull build and the push build (gate G2); the Legacy " + "arm is the one that is meaningful here, and it runs."; + } + switch (m_arm) { + case Arm::Handles: + if (!ThisBackendsRekeyHasLanded()) { + GTEST_SKIP() << "the Handles arm needs the backend's {slot, gen} re-key, and this " + "build does not have it: the build's capability probe found no " + "slot table and no Track H subsystem constant under " + "MobileGL/MG_Backend/" + << Gl().BackendName() + << " (P2 package C for DirectGLES, package D for DirectVulkan). The " + "arm is registered and visible, and arms itself when that " + "package lands in a push build."; + } + return; + case Arm::AbaControl: + if (!BuildMarkerIsSet("MGITEST_HANDLE_ABA_IMPLEMENTED")) { + GTEST_SKIP() << "the AbaControl arm needs MOBILEGL_PIPE_HANDLE_ABA_CONTROL to have a " + "consumer, and this build has none: MG_Config parses the knob " + "(ConfigLoader.cpp) but no source under MobileGL/MG_Backend/ reads " + "Features.PipeHandleAbaControl, so the two guards the knob is " + "supposed to defeat are still in force and the ABA cannot be " + "reproduced. P2 package D owns that consumer."; + } + return; + default: return; + } + } + + // The buffer case's extra gate, on top of the arm gate above. Only the Handles arm + // needs it: `Legacy` asserts today's guards (which exist on every tree) and + // `AbaControl` is gated on the knob's consumer already. + void SkipUnlessTheResourceHandlePathIsAssertableHere() { + if (m_arm != Arm::Handles) return; + if (!ThisBackendsResourceRekeyHasLanded()) { + GTEST_SKIP() << "subsystem not implemented on this tree: the buffer's Handles arm " + "needs this backend's resource_* op table, and the build's capability " + "probe found no source under MobileGL/MG_Backend/" + << Gl().BackendName() + << " naming MGPipeResourceOps. Until it lands, a buffer's backend twin is " + "still resolved from the frontend BufferObject, so there is no " + "{slot, gen} buffer key here to assert about (P3a package C for " + "DirectGLES; Magma's buffer path is P7). The entry stays registered " + "and visible, and arms itself when that package lands in a push " + "build."; + } + } + + // P4a's version of the gate above, for the six object kinds. Only the Handles arm + // needs it: `Legacy` asserts today's address/weak_ptr guards (which exist on every + // tree) and `AbaControl` decides what it expects from ObjectAbaControlIsWiredHere(). + void SkipUnlessTheObjectHandlePathIsAssertableHere(const char* kindName) { + if (m_arm != Arm::Handles) return; + if (!ThisBackendsObjectRekeyHasLanded()) { + GTEST_SKIP() << "subsystem not implemented on this tree: the " << kindName + << "'s Handles arm needs this backend to be keyed on {slot, gen} " + "for P4a's object families, and the build's capability probe " + "found no source under MobileGL/MG_Backend/" + << Gl().BackendName() + << " naming any of kMGPipeSubsystem{Framebuffer, TextureResources, " + "Samplers, Programs}. Until they land, this object's backend " + "twin is still reached from the frontend object, so there is no " + "{slot, gen} key here to assert about (P4a packages D and E for " + "DirectGLES; Magma's object paths are P7). The entry stays " + "registered and visible, and arms itself when that package " + "lands in a push build."; + } + } + + // Says, on EVERY arm and whether or not the case passes, whether the AbaControl arm is + // controlling anything for this kind on this backend - the same reason ExpectPixelsFor + // prints what it observed. A reader of a green AbaControl entry must not have to infer + // which of the two it was. + bool ObjectAbaExpectation(const char* kindName) { + const bool wired = ObjectAbaControlIsWiredHere(); + if (m_arm == Arm::AbaControl) { + std::cout << "[ HandleRecycle ] aba_control kind=" << kindName + << " backend=" << Gl().BackendName() << " wired=" << (wired ? "1" : "0") + << (wired ? " (the knob defeats this kind's identity: the corruption IS " + "the assertion)" + : " (no Features.PipeHandleAbaControl consumer over this " + "backend's P4a object slot tables, so the knob is inert " + "here and this arm asserts the correct pixels - it is not " + "a control for this kind yet)") + << std::endl; + RecordProperty("aba_control_wired", wired ? 1 : 0); + } + return wired; + } + + // A VBO holding one solid-colour quad. + GLuint MakeQuadBuffer(float r, float g, float b) { + const std::vector vertices = Quad(r, g, b); + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glBufferData(GL_ARRAY_BUFFER, + static_cast(vertices.size() * sizeof(Vertex)), vertices.data(), + GL_STATIC_DRAW); + return buffer; + } + + // The attribute configuration, spelled once so the two VAOs are byte-identical. + void ConfigureQuadVao(GLuint vao, GLuint buffer) { + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, x))); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), + reinterpret_cast(offsetof(Vertex, r))); + } + + // The same quad, split across TWO buffers - positions in one, colours in the other, + // one MGPVertexBuffer entry each. What travels in a P3a `set_vertex_buffers` is the + // per-binding BUFFER IDENTITY (D-H3: Res, Offset 0, the resolved stride and + // divisor); the formats live in the vertex-elements blob and are byte-identical + // between the two VAOs by construction. Splitting the set is what lets a PARTIAL + // inheritance be seen: with one buffer, a stale set and a stale everything look the + // same in the pixels. + GLuint MakePositionBuffer() { + const float positions[12] = {-1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, + -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f}; + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW); + return buffer; + } + + GLuint MakeColorBuffer(float r, float g, float b) { + const float colors[18] = {r, g, b, r, g, b, r, g, b, r, g, b, r, g, b, r, g, b}; + GLuint buffer = 0; + glGenBuffers(1, &buffer); + glBindBuffer(GL_ARRAY_BUFFER, buffer); + glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW); + return buffer; + } + + void ConfigureSplitQuadVao(GLuint vao, GLuint positionBuffer, GLuint colorBuffer) { + glBindVertexArray(vao); + glBindBuffer(GL_ARRAY_BUFFER, positionBuffer); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr); + glBindBuffer(GL_ARRAY_BUFFER, colorBuffer); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr); + } + + Image DrawQuadAndRead(GLuint vao) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_colorProgram); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + // A 2x2 RGBA8 texture of one colour, with the sampling parameters spelled the same + // way both times so a parameter-shadow key matches too. + GLuint MakeSolidTexture(std::uint8_t r, std::uint8_t g, std::uint8_t b) { + const std::uint8_t texels[16] = {r, g, b, 255, r, g, b, 255, r, g, b, 255, r, g, b, 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); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0); + return texture; + } + + Image DrawTexturedQuadAndRead(GLuint vao, GLuint texture) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_sampleProgram); + glUniform1i(glGetUniformLocation(m_sampleProgram, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + } + + // ---- G8b: the leak shape, spelled once ------------------------------------------ + // + // DestroyedVertexArraysReturnTheirVertexElementsSlots below is the original; P4a adds + // one case per kind it mints, and seven copies of a twenty-line assertion block is how + // six of them quietly stop asserting the same thing. So the block lives here and each + // case supplies only its own churn round. + // + // `round(checkPixels, observe)` must create ONE object of the kind, put it through + // whatever makes the client mint its slot (which for every P4a kind means reaching a + // validate point - an object created and destroyed without a draw has no record and no + // slot), call `observe()` WHILE THE OBJECT IS STILL ALIVE, and then destroy it. + // + // `observe()` is where peakLive is sampled, and it has to be inside the round rather + // than after it: sampled after the destroy it would only ever see the resting count, + // and the "deaths arrive at the destructor rather than late" assertion below would be + // vacuous - which is the one of the three that catches a death path that works but + // runs at the wrong time (a deferred queue, a frame-boundary sweep). + // + // `space` says WHICH of the allocator's two spaces the three assertions are about, and + // it is the difference between an assertion and a green that reads the wrong counter: + // only ShaderCso has two, and only the composite case is about the band. + // + // `maxInFlight` is how many slots of the kind IN THAT SPACE one round may legitimately + // hold at its peak: 1 where the round creates one object of it, more where the round + // creates several. The composite round creates three ShaderCsos - two stage programs + // and the composite they are flattened into - but only ONE of the three is a band + // slot, so the band's answer is 1 and the ordinary space's would have been 3. + // `warmUpRounds` is how many rounds run BEFORE the baseline is taken, and for one + // kind it is not two. + // + // A CONTENT-ADDRESSED KIND'S SLOT IS NOT THE OBJECT'S (D-F1, ID-17's reference-count + // ruling). SamplerCso is minted by a CACHE keyed on the parameter block: destroying + // the frontend sampler releases its REFERENCE, and the entry then stays in the cache, + // unreferenced, until LRU eviction at capacity 256. So a churn over N DISTINCT + // parameter sets legitimately retains N slots however many objects carried them, and + // "48 objects, 14 slots not returned" is the cache working, not P3a's C-1 leak. + // Asserting the flat count there measures the cache's capacity policy and calls it a + // leak - which is exactly what this case did on the tree where package C landed. + // + // The fix is not a weaker assertion but a warmer cache: run enough warm-up rounds to + // walk EVERY distinct content once, so that the baseline is taken with the cache full + // and the measured churn re-uses entries that already exist. The three assertions + // below then say something STRONGER than they could for a per-object kind - a warm + // content-addressed cache must not grow AT ALL under churn - and an unbounded leak, + // which is what C-1 is about, still moves every one of them. + using ChurnRound = std::function& observe)>; + void AssertChurnReturnsEverySlot(PipeSlotKind kind, SlotSpace space, const char* kindName, + const char* owner, unsigned maxInFlight, + const ChurnRound& round, unsigned warmUpRounds = 2u) { + // THE LANE'S OWN PIN, not the arm (F-m4). The Legacy and AbaControl lanes run + // MOBILEGL_PIPE_PUSH=0 and really have no allocator to leak from; the Handles + // lanes and DirectVulkan.HandleRecycle.AbaControlHandles. all pin the shipping + // 0x1fff and do. The old gate declined the third of those while telling the + // reader it had no allocator, which was false, and left one lane's coverage on + // the table. + if (!LanePinnedALiveAllocator()) { + GTEST_SKIP() << "this entry pinned no non-zero MOBILEGL_PIPE_PUSH, so there is " + "no client slot allocator behind it to leak from: the " + "HandleRecycle.Legacy. and HandleRecycle.AbaControl. lanes pin " + "MOBILEGL_PIPE_PUSH=0 on purpose (they are about the pre-handle " + "guards), and the ambient entries configure no lane at all. The " + "lanes that carry this assertion are the two " + "HandleRecycle.Handles. ones and " + "DirectVulkan.HandleRecycle.AbaControlHandles., all of which pin " + "the shipping mask."; + } + unsigned probe = 0; + if (!ReadSpaceLiveCount(kind, space, &probe)) { + GTEST_SKIP() << "the client slot allocator is out of reach from this module (a " + "pull build has none, and the Android link resolves no internal " + "symbol), so 'could not look' would be reported as 'did not " + "leak'"; + } + unsigned highWaterFloor = 0; + if (!ReadSpaceHighWaterFloor(space, &highWaterFloor)) { + GTEST_SKIP() << "the composite band's base is out of reach from this module, so " + "'no composite was ever minted' cannot be told apart from 'the " + "band did not grow' and a green here would assert nothing"; + } + + // TWO WARM-UP ROUNDS BEFORE THE BASELINE IS TAKEN, so what is measured is growth + // WITH the churn and not the one-off cost of drawing at all. The first rounds in a + // process mint slots that legitimately never come back inside this case - the + // default vertex array's, the scene's own program's - and the second round is what + // proves the steady state has been reached, since a per-round leak would still be + // growing at that point. + unsigned peakLive = 0; + const std::function observe = [&]() { + unsigned live = 0; + if (ReadSpaceLiveCount(kind, space, &live) && live > peakLive) peakLive = live; + }; + // The first round checks pixels; the rest only churn. Two is the floor and the + // default (see the parameter's note): the first mints the one-off slots any draw + // needs and the second proves the steady state has been reached. + ASSERT_GE(warmUpRounds, 2u) << "the warm-up has to reach a steady state"; + round(/*checkPixels=*/true, observe); + for (unsigned i = 1; i < warmUpRounds; ++i) round(/*checkPixels=*/false, observe); + peakLive = 0; + + unsigned liveBefore = 0; + unsigned highWaterBefore = 0; + ASSERT_TRUE(ReadSpaceLiveCount(kind, space, &liveBefore)); + ASSERT_TRUE(ReadSpaceHighWater(kind, space, &highWaterBefore)); + + constexpr int kChurn = 48; + for (int i = 0; i < kChurn; ++i) round(/*checkPixels=*/false, observe); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the churn left a GL error behind"; + + unsigned liveAfter = 0; + unsigned highWaterAfter = 0; + ASSERT_TRUE(ReadSpaceLiveCount(kind, space, &liveAfter)); + ASSERT_TRUE(ReadSpaceHighWater(kind, space, &highWaterAfter)); + std::cout << "[ HandleRecycle ] backend=" << Gl().BackendName() << " " << kindName + << SpaceSuffix(space) << " live " << liveBefore << " -> " << liveAfter + << " (peak " << peakLive << "), high water " << highWaterBefore << " -> " + << highWaterAfter << " (floor " << highWaterFloor << ") over " << kChurn + << " create/draw/destroy rounds" << std::endl; + + // NOTHING WAS EVER MINTED, which is not "did not leak" and must not be reported as + // one. On the P4a contract tree the client emits nothing for any of these kinds - + // the five emit headers are the contract's stubs (contract-v1 D1) - so every + // assertion below would be 0 == 0 and the case would be a green that asserts about + // a kind it never saw. This is the same rule as the peek returning false, applied + // to the other way of not being able to look, and it arms itself the moment the + // owning package's emitter lands. + if (highWaterAfter == highWaterFloor && peakLive == 0 && liveAfter == 0) { + GTEST_SKIP() << "subsystem not implemented on this tree: the client minted no " + << kindName << SpaceSuffix(space) + << " slot at all over " << (kChurn + 2) + << " create/draw/destroy rounds, so there is nothing here that " + "could leak and a green would assert nothing. P4a package " + << owner + << " owns the emitter that mints it; this case arms itself when it " + "lands."; + } + + EXPECT_EQ(liveAfter, liveBefore) + << kChurn << " " << kindName << SpaceSuffix(space) + << " objects were created, drawn with and destroyed and " + << (liveAfter - liveBefore) + << " slots never came back. Each one holds a SlotState, a lifetime-id map node " + "and the applier's record for the life of the process, and past the kind's " + "slot bound every create trips Fatal{ProtocolCorruption} for good " + "(PipeApply.h:97-113). This is P3a's C-1 defect, which is why every P4a kind " + "frees its slot from the frontend destructor through one client-side helper " + "(D-I1) rather than from a backend death table. Backend " + << Gl().BackendName(); + EXPECT_EQ(highWaterAfter, highWaterBefore) + << "the " << kindName << SpaceSuffix(space) + << " slot space grew with the churn instead of recycling the slot the warm-up " + "rounds already handed out; the frees are not reaching the allocator's free " + "list"; + EXPECT_LE(peakLive > liveBefore ? peakLive - liveBefore : 0u, maxInFlight) + << "more than " << maxInFlight << " churned " << kindName << SpaceSuffix(space) + << " object(s) were live at the allocator at once, so the deaths are arriving " + "late rather than at the destructor"; + } + + Arm m_arm = Arm::Legacy; + GLuint m_colorProgram = 0; + GLuint m_sampleProgram = 0; + }; + + // ------------------------------------------------------------------------------------ + // The self-check. Without it the three cases below could all be green because the name + // allocator never repeated itself, i.e. because the ABA never happened. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, TheReproducerRecyclesEveryName) { + if (!Ready()) return; + + GLuint vao = 0; + glGenVertexArrays(1, &vao); + glBindVertexArray(vao); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint vaoAgain = 0; + glGenVertexArrays(1, &vaoAgain); + EXPECT_EQ(vao, vaoAgain) << "glGenVertexArrays did not hand the deleted name back, so the " + "vertex-array case below cannot be constructing an ABA"; + glDeleteVertexArrays(1, &vaoAgain); + + GLuint texture = 0; + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glBindTexture(GL_TEXTURE_2D, 0); + glDeleteTextures(1, &texture); + GLuint textureAgain = 0; + glGenTextures(1, &textureAgain); + EXPECT_EQ(texture, textureAgain) << "glGenTextures did not hand the deleted name back"; + glDeleteTextures(1, &textureAgain); + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &fbo); + GLuint fboAgain = 0; + glGenFramebuffers(1, &fboAgain); + EXPECT_EQ(fbo, fboAgain) << "glGenFramebuffers did not hand the deleted name back"; + glDeleteFramebuffers(1, &fboAgain); + + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + } + + // ------------------------------------------------------------------------------------ + // 1. The vertex array. This is the case the AbaControl knob targets: DirectVulkan keys + // VertexInputStateFactory's cache on the attribute's buffer identity and VaoDrawMemo + // on the VAO's. Only the VAO is recycled here: both buffers are created before the + // window and neither is deleted inside it, because buffer traffic in the window moves + // VkBufferManager's slice-epoch counter and that gate is not an identity gate (see + // the two MakeQuadBuffer calls). What is recycled is the GL NAME; the heap block is + // not handed back, which is why the knob - not the allocator - constructs the + // AbaControl arms' collision. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexInput) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + // BOTH buffers are created, and both are DRAWN WITH, before the recycle happens. + // Creating a buffer - or touching one for the first time - moves VkBufferManager's + // manager-wide slice-epoch counter, and a moved counter sends the resolved-bindings + // memo into a revalidation that re-reads every binding from the live VAO. That gate is + // not an identity gate and it is not what this case is about, so both buffers are + // realised up front and the ABA window contains no buffer traffic at all. + const GLuint redBuffer = MakeQuadBuffer(1.0f, 0.0f, 0.0f); + const GLuint greenBuffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f); + + GLuint primerVao = 0; + glGenVertexArrays(1, &primerVao); + ConfigureQuadVao(primerVao, greenBuffer); + const Image primed = DrawQuadAndRead(primerVao); + ExpectWholeViewportIs(primed, "green", "priming the replacement's buffer"); + + GLuint redVao = 0; + glGenVertexArrays(1, &redVao); + ConfigureQuadVao(redVao, redBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first VAO left a GL error behind"; + + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawQuadAndRead(redVao); + ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame)); + } + + // ---- the ABA window: ONE frame, two draws ---- + // + // The arming draw and the recycled draw share a frame because + // ResolvedVertexBindings - the only memo that carries a GPU slice rather than a + // layout - declines across frames by design. See the header. + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_colorProgram); + glBindVertexArray(redVao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + + // Unbind FIRST: a still-bound object keeps living, so the last SharedPtr would not + // drop and the object would not die here at all. + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &redVao); + + // The replacement, immediately, byte-identically configured, and reading the OTHER + // buffer - so its pixels differ from its predecessor's by exactly the thing a stale + // vertex binding would get wrong. + GLuint greenVao = 0; + glGenVertexArrays(1, &greenVao); + ConfigureQuadVao(greenVao, greenBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement VAO left a GL error behind"; + + glBindVertexArray(greenVao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + + // The name proxy. It no longer constructs the AbaControl arm's collision - the knob + // does that, deterministically, because the heap block is never handed back (header) - + // but it is still what makes this a RECYCLE rather than two unrelated objects, and it + // is what the Handles and Legacy arms are asserting is not enough to inherit anything. + if (greenVao != redVao) { + GTEST_SKIP() << "inconclusive, not proven: glGenVertexArrays returned " << greenVao + << " rather than the deleted " << redVao << ", so no ABA was constructed"; + } + RecordProperty("recycled_vao_name", static_cast(greenVao)); + + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, image, "green", "red", + "the draw after the VAO was recycled inside one frame"); + + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + GLuint cleanupVaos[2] = {greenVao, primerVao}; + glDeleteVertexArrays(2, cleanupVaos); + GLuint cleanupBuffers[2] = {redBuffer, greenBuffer}; + glDeleteBuffers(2, cleanupBuffers); + } + + // ------------------------------------------------------------------------------------ + // 1a. The same recycle, over a vertex-input SET of TWO buffers - the shape a P3a + // `set_vertex_buffers` actually has. + // + // The case above swaps the ONE buffer its VAO reads, so "inherited the dead VAO's + // vertex input" and "inherited the dead VAO's everything" are the same picture. P3a + // splits that record in two: the formats travel once per configuration, in + // `create_vertex_elements`' blob, and the per-binding BUFFER IDENTITIES travel in + // `set_vertex_buffers` (D-G2, D-H3). So the replacement here shares its predecessor's + // POSITION buffer and differs in the COLOUR buffer alone: the elements blob is + // byte-identical between the two VAOs, exactly one entry of the buffer set moved, and + // a replacement that inherited the dead VAO's set draws its own geometry in the dead + // VAO's colour. A single-buffer window cannot produce that picture. + // + // A SEPARATE CASE RATHER THAN A SECOND WINDOW IN THE ONE ABOVE, and the reason is + // measured. gtest_discover_tests registers one ctest entry per case, so a case is a + // PROCESS; the AbaControl knob collapses every VAO in a process onto one memo entry, + // and that entry carries the resolved vertex-input LAYOUT as well as the bindings. + // Two windows in one process therefore means the second window's VAOs inherit the + // first window's layout - and these VAOs deliberately do NOT share the first's + // (interleaved stride 20 there, two tight arrays here). Run as a second phase, the + // AbaControl arms read the split VAOs' vertices through the interleaved layout and + // every draw in the phase, priming and warm-up included, came back as garbage + // (measured: "should be all green ... first offender is blue"). That is not the + // identity claim failing, it is the arm's own knob poisoning the setup - so the + // window gets a process of its own, where every VAO carries the same layout and the + // buffer identity is again the only thing that differs. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, + AVertexArrayAtARecycledAddressDoesNotInheritItsPredecessorsVertexBufferSet) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint positionBuffer = MakePositionBuffer(); + const GLuint redColorBuffer = MakeColorBuffer(1.0f, 0.0f, 0.0f); + const GLuint greenColorBuffer = MakeColorBuffer(0.0f, 1.0f, 0.0f); + + // Same rule as the case above: every buffer is realised and DRAWN WITH before the + // window, so the window contains no buffer traffic and the slice-epoch gate - which + // is not an identity gate - is not what decides the verdict. + GLuint splitPrimerVao = 0; + glGenVertexArrays(1, &splitPrimerVao); + ConfigureSplitQuadVao(splitPrimerVao, positionBuffer, greenColorBuffer); + const Image splitPrimed = DrawQuadAndRead(splitPrimerVao); + ExpectWholeViewportIs(splitPrimed, "green", "priming the split replacement's colour buffer"); + + GLuint splitRedVao = 0; + glGenVertexArrays(1, &splitRedVao); + ConfigureSplitQuadVao(splitRedVao, positionBuffer, redColorBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the split VAO left a GL error behind"; + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawQuadAndRead(splitRedVao); + ExpectWholeViewportIs(warm, "red", "split warm-up frame " + std::to_string(frame)); + } + + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_colorProgram); + glBindVertexArray(splitRedVao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &splitRedVao); + + GLuint splitGreenVao = 0; + glGenVertexArrays(1, &splitGreenVao); + ConfigureSplitQuadVao(splitGreenVao, positionBuffer, greenColorBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) + << "building the split replacement VAO left a GL error behind"; + glBindVertexArray(splitGreenVao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image splitImage = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + + if (splitGreenVao != splitRedVao) { + GTEST_SKIP() << "inconclusive, not proven: glGenVertexArrays returned " << splitGreenVao + << " rather than the deleted " << splitRedVao + << ", so no ABA was constructed for the split vertex-buffer set"; + } + RecordProperty("recycled_split_vao_name", static_cast(splitGreenVao)); + + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, splitImage, "green", "red", + "the draw after a VAO reading a two-buffer vertex set was recycled inside " + "one frame"); + + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + GLuint splitCleanupVaos[2] = {splitGreenVao, splitPrimerVao}; + glDeleteVertexArrays(2, splitCleanupVaos); + GLuint splitCleanupBuffers[3] = {positionBuffer, redColorBuffer, greenColorBuffer}; + glDeleteBuffers(3, splitCleanupBuffers); + } + + + // ------------------------------------------------------------------------------------ + // 1b. The BUFFER (P3a). Today a buffer's backend twin is reached from the frontend + // object: Espryt keys GLESBufferResource off the BufferObject's SharedPtr and + // re-probes it per draw through IsBufferDrawClean, and Magma mixes the + // BufferObject's lifetime id into VertexInputStateFactory's content hash - the guard + // commit 66b3b6e2 added after a destroyed buffer's GPU slice was bound for its + // successor's draw. P3a replaces that reachability with a client-minted handle: the + // store lives in a slot table, ~BufferObject emits `resource_destroy` and THEN frees + // the slot (D-L), and the next buffer is handed the same slot with Gen + 1. + // + // This case is the pixel-level question about that swap: does a buffer created + // immediately after another one died, at the same GL name and the same {slot}, get + // its own bytes? It is the buffer twin of the vertex-array case above and it is + // written FIRST, against the P3a contract commit, so that whatever the Legacy arm + // reports here is on the record before package C touches a backend. + // + // THE VAO IS NOT RECYCLED HERE - it is created once and outlives the whole case. + // Only the buffer dies. That is also why the VAO's attributes are PARKED on a + // buffer that never dies before the delete: a VAO attribute holds a + // SharedPtr (MGPipeValueTypes.h:516), so while the VAO still points at + // the doomed buffer the frontend object cannot die, glDeleteBuffers only unnames it, + // and there would be no recycle to construct at all. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, ABufferAtARecycledAddressDoesNotInheritItsPredecessorsContents) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + SkipUnlessTheResourceHandlePathIsAssertableHere(); + if (IsSkipped()) return; + + // Both survivors are realised and drawn with before the window, for the reason the + // vertex-array case gives: a buffer touched for the first time moves the manager-wide + // slice epoch, and that gate is not an identity gate. + const GLuint parkingBuffer = MakeQuadBuffer(0.0f, 0.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, parkingBuffer); + const Image parked = DrawQuadAndRead(vao); + ExpectWholeViewportIs(parked, "blue", "priming the parking buffer"); + + const GLuint redBuffer = MakeQuadBuffer(1.0f, 0.0f, 0.0f); + ConfigureQuadVao(vao, redBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first buffer left a GL error behind"; + + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawQuadAndRead(vao); + ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame)); + } + + // ---- the ABA window: ONE frame, two draws ---- + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_colorProgram); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + + // Let go of the doomed buffer - from the VAO's attributes and from the binding point - + // and only then delete it, so the frontend object really dies here. + ConfigureQuadVao(vao, parkingBuffer); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + GLuint doomed = redBuffer; + glDeleteBuffers(1, &doomed); + + // The replacement, immediately, with the same size and the same layout - so a store + // pooled by size, a twin resolved by identity or a content hash over the + // configuration all match the dead buffer's - and DIFFERENT CONTENTS, which is the + // only thing that differs and the only thing the pixels can show. + const GLuint greenBuffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f); + ConfigureQuadVao(vao, greenBuffer); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) + << "building the replacement buffer left a GL error behind"; + + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + + if (greenBuffer != redBuffer) { + GTEST_SKIP() << "inconclusive, not proven: glGenBuffers returned " << greenBuffer + << " rather than the deleted " << redBuffer << ", so no ABA was constructed"; + } + RecordProperty("recycled_buffer_name", static_cast(greenBuffer)); + + // The AbaControl arm expects the CORRUPTION here, and that this window can produce + // it at all is a measurement rather than an assumption. MOBILEGL_PIPE_HANDLE_ABA_CONTROL + // has two consumers, both Magma's, and the one that decides this case is + // VertexInputStateFactory::ComputeHash's `bufferKey = 0`: with the BUFFER's identity + // gone from the vertex-input content hash, TryBindResolvedVertexBindings accepts a + // binding resolved from the dead buffer as proof that it still reads the live one - + // the exact defect that hash was fixed for. The open question was whether the + // guards the control deliberately leaves standing would mask it, because one of + // them, VkBufferManager's manager-wide slice epoch, MOVES when the replacement is + // created and the replacement is created INSIDE this window by construction (a + // buffer ABA cannot be built without creating a buffer in it). It does not: on the + // contract tree both AbaControl lanes read the dead buffer's colour over 100% of + // the viewport ("first offender at (2,2) is red rgba(255,0,0,255)"). So the control + // reaches the buffer path too, and the line below records what was observed on + // EVERY arm, whether or not the case passes. + // + // What it does NOT reach is Espryt - the knob has no DirectGLES consumer, which is + // why the AbaControl arm is registered on DirectVulkan lanes only - nor the + // {slot, gen} GENERATION, for the reason MagmaPipeAbaControlDefeatsIdentity gives. + // Package C's buffer re-key is where a Features.PipeHandleAbaControl consumer over + // the resource slot table would go, the way MagmaPipeClaimSlotMemos is Magma's. + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/true, image, "green", "red", + "the draw after the buffer was recycled inside one frame"); + + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffers[2] = {parkingBuffer, greenBuffer}; + glDeleteBuffers(2, cleanupBuffers); + } + + // ------------------------------------------------------------------------------------ + // 2. The texture. DirectGLES keeps a backend twin per frontend texture in a registry + // keyed on the frontend object's address (StateBackendObjectRegistry + the + // UnitSamplerLookupMemo's weak_ptr test); a replacement at the same address must not + // sample the dead texture's driver object. + // + // P4a MADE THIS A REAL ABA CONTROL (G8, D-I2). Until P4a the case expected the correct + // pixels on EVERY arm, with the note that "the AbaControl knob does not steer this + // path" - true, and vacuous the moment P4a re-keys the texture twin on {slot, gen}: a + // control that only defeats guards nobody ships says nothing about the key that does. + // So the expectation is now ObjectAbaExpectation()'s answer - "expect the corruption" + // exactly where the knob really reaches this kind on this backend, "expect the correct + // pixels, and SAY that this arm is not a control here" where it does not. See + // ObjectAbaControlIsWiredHere for why the second is today's answer on both backends and + // for the one change that flips it. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, ATextureAtARecycledAddressDoesNotInheritItsPredecessorsTwin) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + SkipUnlessTheObjectHandlePathIsAssertableHere("texture"); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + const GLuint redTexture = MakeSolidTexture(255, 0, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first texture left a GL error behind"; + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = DrawTexturedQuadAndRead(vao, redTexture); + ExpectWholeViewportIs(warm, "red", "warm-up frame " + std::to_string(frame)); + } + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint doomed = redTexture; + glDeleteTextures(1, &doomed); + + const GLuint greenTexture = MakeSolidTexture(0, 255, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement texture left a GL error"; + if (greenTexture != redTexture) { + GTEST_SKIP() << "inconclusive, not proven: glGenTextures returned " << greenTexture + << " rather than the deleted " << redTexture << ", so no ABA was constructed"; + } + RecordProperty("recycled_texture_name", static_cast(greenTexture)); + + const Image image = DrawTexturedQuadAndRead(vao, greenTexture); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/ObjectAbaExpectation("texture"), image, + "green", "red", "the draw after the texture was recycled"); + + glBindTexture(GL_TEXTURE_2D, 0); + GLuint cleanupTexture = greenTexture; + glDeleteTextures(1, &cleanupTexture); + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + // ------------------------------------------------------------------------------------ + // 3. The framebuffer. The readback is deliberately NOT from the framebuffer under test: + // a clear that landed in the WRONG framebuffer would still read back green through + // that framebuffer. It is taken from the replacement's own attachment with + // glGetTexImage, so "the clear went somewhere else" is visible as a texture that + // never became green. + // + // P4a MADE THIS A REAL ABA CONTROL TOO (G8, D-I2), and it needed one more thing than + // the texture case did: somewhere for the corruption to be VISIBLE. "The replacement's + // attachment never became green" is only half a verdict - it does not say where the + // clear went. So the dead framebuffer's attachment is cleared to RED in the warm-up and + // read back beside the replacement's at the end: FRESH is (second green, first red), + // STALE is (first green) - the clear reached a framebuffer this one only shares an + // address with. A framebuffer is the kind with a handle and NO wire lifetime (D-I2): + // the applier learns of it only through set_framebuffer_state keyed by Fbo, and a + // recycled handle is told apart by Gen, which is inside ContentHash - so the knob has + // to defeat the identity half of that memo key for this to reproduce at all. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, AFramebufferAtARecycledAddressDoesNotInheritItsPredecessorsTwin) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + SkipUnlessTheObjectHandlePathIsAssertableHere("framebuffer"); + if (IsSkipped()) return; + + // Two attachments that stay alive for the whole case, so the only recycled object is + // the framebuffer itself. + GLuint firstAttachment = 0; + GLuint secondAttachment = 0; + glGenTextures(1, &firstAttachment); + glBindTexture(GL_TEXTURE_2D, firstAttachment); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glGenTextures(1, &secondAttachment); + glBindTexture(GL_TEXTURE_2D, secondAttachment); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + + GLuint firstFbo = 0; + glGenFramebuffers(1, &firstFbo); + glBindFramebuffer(GL_FRAMEBUFFER, firstFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, firstAttachment, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + for (int frame = 0; frame < kWarmupFrames; ++frame) { + glBindFramebuffer(GL_FRAMEBUFFER, firstFbo); + glViewport(0, 0, 4, 4); + ClearTo(1.0f, 0.0f, 0.0f, 1.0f); + BindDefaultFramebuffer(); + Gl().EndFrame(); + } + BindDefaultFramebuffer(); + glDeleteFramebuffers(1, &firstFbo); + + GLuint secondFbo = 0; + glGenFramebuffers(1, &secondFbo); + if (secondFbo != firstFbo) { + glDeleteFramebuffers(1, &secondFbo); + GLuint cleanup[2] = {firstAttachment, secondAttachment}; + glDeleteTextures(2, cleanup); + GTEST_SKIP() << "inconclusive, not proven: glGenFramebuffers returned " << secondFbo + << " rather than the deleted " << firstFbo << ", so no ABA was constructed"; + } + RecordProperty("recycled_framebuffer_name", static_cast(secondFbo)); + + glBindFramebuffer(GL_FRAMEBUFFER, secondFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, secondAttachment, 0); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + glViewport(0, 0, 4, 4); + ClearTo(0.0f, 1.0f, 0.0f, 1.0f); + BindDefaultFramebuffer(); + Gl().EndFrame(); + + // Read BOTH attachments, not the framebuffer: that is what makes "the clear landed in + // the dead framebuffer" visible as a place rather than as an absence. + const auto readAttachment = [&](GLuint texture) { + std::vector texels(4 * 4 * 4, 0); + glBindTexture(GL_TEXTURE_2D, texture); + glGetTexImage(GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, texels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + return texels; + }; + const auto offendersAgainst = [](const std::vector& texels, int r, int g, + int b) { + int offenders = 0; + for (std::size_t i = 0; i < texels.size(); i += 4) { + if (texels[i] != r || texels[i + 1] != g || texels[i + 2] != b) ++offenders; + } + return offenders; + }; + const std::vector replacementTexels = readAttachment(secondAttachment); + const std::vector deadTexels = readAttachment(firstAttachment); + EXPECT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)); + + const int replacementIsNotGreen = offendersAgainst(replacementTexels, 0, 255, 0); + const int deadIsNotRed = offendersAgainst(deadTexels, 255, 0, 0); + const bool sawStale = deadIsNotRed != 0 && offendersAgainst(deadTexels, 0, 255, 0) == 0; + std::cout << "[ HandleRecycle ] arm=" << ArmName(m_arm) << " framebuffer observed=" + << (sawStale ? "STALE (the clear reached the DEAD framebuffer's attachment)" + : (replacementIsNotGreen == 0 + ? "FRESH (the clear reached the replacement's own " + "attachment)" + : "NEITHER")) + << std::endl; + + if (ObjectAbaExpectation("framebuffer") && m_arm == Arm::AbaControl) { + // The corruption IS the assertion: with the identity half of the framebuffer memo + // key defeated, the replacement inherits the dead framebuffer's record and its + // clear lands on the dead one's attachment. + // sawStale, not `deadIsNotRed != 0` (review F-m12): the weaker form is satisfied + // by a dead attachment full of GARBAGE, which is not the observation this control + // claims. sawStale is the predicate the case already computes and prints - the + // dead attachment is now the REPLACEMENT'S green, i.e. the replacement's clear + // landed there - so the assertion and the printed line say the same thing. + EXPECT_TRUE(sawStale) + << "[AbaControl expects the STALE framebuffer] the DEAD framebuffer's " + "attachment did not come back as the replacement's green, so the " + "replacement's clear did not land on it and the ABA was not reproduced - " + "the control has stopped controlling anything. Texels that are neither the " + "warm-up red nor the replacement green are a third answer and are not a " + "reproduction either (" + << deadIsNotRed << " of " << (deadTexels.size() / 4) + << " dead texels are not red)."; + } else { + EXPECT_EQ(replacementIsNotGreen, 0) + << "the replacement framebuffer's own attachment is not the colour it was " + "cleared to, so the clear reached a framebuffer this one only shares an " + "address with (first texel rgba=" + << static_cast(replacementTexels[0]) << "," + << static_cast(replacementTexels[1]) << "," + << static_cast(replacementTexels[2]) << "," + << static_cast(replacementTexels[3]) << ")"; + EXPECT_EQ(deadIsNotRed, 0) + << "the DEAD framebuffer's attachment changed colour, so the replacement's " + "clear reached the framebuffer it only shares a name with"; + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glDeleteFramebuffers(1, &secondFbo); + GLuint cleanup[2] = {firstAttachment, secondAttachment}; + glDeleteTextures(2, cleanup); + } + + // ------------------------------------------------------------------------------------ + // P3a C-1: the recycle has to give the SLOT back, not only refuse to alias. + // + // Everything above asks "did the replacement inherit the dead object's state". This asks + // the other half of the same identity contract, which no case in this file could see: + // when the dead object is not replaced at all, does the client hand its {slot, gen} + // back? Until C-1 the answer under DirectVulkan was NO. The mint is the client's + // (MGPipeVertexInputEmitter::EmitVertexElements acquires a VertexElementsCso slot at + // every validate point with a VAO bound) and the only free in the tree was Espryt's + // StateObjectDeathOps consumer - so under Magma, which installs none deliberately + // (MagmaPipeArms.h: "an allocator here would grow by one SlotState plus one map node + // per object EVER created, for the life of the process, on a platform with an LMK"), + // every VAO ever created held its slot and its ~1.3 KB applier record until the process + // died, on the shipped 0x1ff mask, until create_vertex_elements began tripping + // Fatal{ProtocolCorruption} permanently at kMGPipeMaxVertexElementsSlots. + // + // THE OBSERVABLE IS THE ALLOCATOR, not pixels: this leak produces correct pictures the + // whole way to the fatal, which is exactly why the four cases above ran green over it. + // It runs on BOTH backends' Handles lanes; DirectVulkan is where it was red. + TEST_F(HandleRecycleScenario, DestroyedVertexArraysReturnTheirVertexElementsSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + if (m_arm != Arm::Handles) { + GTEST_SKIP() << "the client mints a VertexElementsCso slot only when the vertex-input " + "subsystem is on, and only the Handles arm pins it (0x1ff). The Legacy " + "and AbaControl lanes run MOBILEGL_PIPE_PUSH=0, where there is no " + "allocator to leak from."; + } + + unsigned probe = 0; + if (!MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso, &probe)) { + GTEST_SKIP() << "the client slot allocator is out of reach from this module (a pull " + "build has none, and the Android link resolves no internal symbol), so " + "'could not look' would be reported as 'did not leak'"; + } + + // One shared VBO: the case is about the VAOs, and a per-round buffer would churn the + // Buffer kind's slots alongside them and blur which allocator answered. + const GLuint buffer = MakeQuadBuffer(0.0f, 1.0f, 0.0f); + + // One round: create a vertex array, DRAW with it - which is what mints the slot and + // publishes the applier's record; a VAO that never reaches a validate point has + // neither - unbind it and delete it. glGenVertexArrays hands the same name back every + // time, exactly as a chunk renderer's does, so a death path that keyed on the GL NAME + // rather than on the lifetime id would look correct here too, which is why the + // assertion is on the allocator and not on the name. + unsigned peakLive = 0; + const auto round = [&](const char* when, bool checkPixels) { + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + const Image image = DrawQuadAndRead(vao); + if (checkPixels) { + // One picture check, so a green here cannot mean "the draws never happened + // and therefore nothing was ever minted". + ExpectWholeViewportIs(image, "green", when); + } + unsigned live = 0; + if (MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso, &live) && + live > peakLive) { + peakLive = live; + } + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + }; + + // TWO WARM-UP ROUNDS BEFORE THE BASELINE IS TAKEN, so what is measured is growth WITH + // the churn and not the one-off cost of drawing at all. The first draws in a process + // mint slots that legitimately never come back inside this case - the DEFAULT vertex + // array's above all, which this scenario's frames bind and which lives as long as the + // context does - and the second round is what proves the steady state has been + // reached, since a per-round leak would still be growing at that point. Sampling + // before them would score a one-off as the churn's leak; sampling after makes the + // assertion the exact one that matters: "N more create/destroy cycles cost ZERO more + // slots", with no slack in it. + round("the first warm-up draw", /*checkPixels=*/true); + round("the second warm-up draw", /*checkPixels=*/false); + peakLive = 0; + + unsigned liveBefore = 0; + unsigned highWaterBefore = 0; + ASSERT_TRUE(MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso, + &liveBefore)); + ASSERT_TRUE(MGITest::PeekPipeSlotHighWater(MGITest::PipeSlotKind::VertexElementsCso, + &highWaterBefore)); + + constexpr int kChurn = 48; + for (int i = 0; i < kChurn; ++i) round("a churn draw", /*checkPixels=*/false); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "the churn left a GL error behind"; + + unsigned liveAfter = 0; + unsigned highWaterAfter = 0; + ASSERT_TRUE(MGITest::PeekPipeSlotLiveCount(MGITest::PipeSlotKind::VertexElementsCso, + &liveAfter)); + ASSERT_TRUE(MGITest::PeekPipeSlotHighWater(MGITest::PipeSlotKind::VertexElementsCso, + &highWaterAfter)); + std::cout << "[ HandleRecycle ] backend=" << Gl().BackendName() << " VertexElementsCso live " + << liveBefore << " -> " << liveAfter << " (peak " << peakLive << "), high water " + << highWaterBefore << " -> " << highWaterAfter << " over " << kChurn + << " create/draw/destroy rounds" << std::endl; + + EXPECT_EQ(liveAfter, liveBefore) + << kChurn << " vertex arrays were created, drawn with and destroyed and " << (liveAfter - liveBefore) + << " VertexElementsCso slots never came back. Each one holds a SlotState, a " + "lifetime-id map node and the applier's ~1.3 KB record for the life of the " + "process, and past kMGPipeMaxVertexElementsSlots every create_vertex_elements " + "trips Fatal{ProtocolCorruption} for good. Backend " + << Gl().BackendName(); + EXPECT_EQ(highWaterAfter, highWaterBefore) + << "the CSO slot space grew with the churn instead of recycling the one slot the " + "warm-up round already handed out; the frees are not reaching the allocator's " + "free list"; + EXPECT_LE(peakLive - liveBefore, 1u) + << "more than one churned vertex array was live at the allocator at once, so the " + "deaths are arriving late rather than at the destructor"; + + glDeleteBuffers(1, &buffer); + } + + // ==================================================================================== + // P4a (G8, G8b). Six more kinds, and the same two questions about each of them. + // + // WHAT IS NEW HERE, stated once so the cases below can stay short. P4a mints Texture, + // Renderbuffer, Framebuffer, SamplerCso, SamplerViewCso and ShaderCso handles on the + // client and frees every one of them from the frontend object's own destructor, through + // one helper per kind, in one fixed order: emit the wire delete, raise the death notice, + // free the slot (D-I1). That shape exists because of P3a's C-1 - a client-minted CSO whose + // only free was a backend death table Magma does not install, which leaked a slot and a + // ~1.3 KB record per VAO until the process Fatal'd - so the leak cases below run on the + // DirectVulkan Handles lane as well as the DirectGLES one, exactly as ID-8 requires. + // + // Two kinds carry a wrinkle the others do not: + // * FRAMEBUFFER has a handle and NO wire lifetime (D-I2). There is no framebuffer row in + // the call catalogue at all: the applier learns of one only through + // set_framebuffer_state keyed by Fbo, and the death helper does the notice and the + // free and emits nothing. The allocator is therefore the ONLY observable of a + // framebuffer's lifetime, which is what the leak case reads. + // * SHADERCSO covers ordinary programs AND the program-pipeline COMPOSITES minted from + // the reserved high band (D-H7). A composite's slot has TWO independent release paths + // - the pipeline cache's LRU eviction and the composite ProgramObject's destructor - + // which is why it gets a case of its own; the second free is a proven no-op + // (SlotAllocator.cpp:117-119) and this is where "proven" is measured rather than + // asserted in a comment. + // ==================================================================================== + + // ------------------------------------------------------------------------------------ + // 4. The renderbuffer. Espryt keeps a backend twin per frontend renderbuffer in the same + // address-keyed registry the texture uses, and P4a re-keys it on {slot, gen}. + // + // The replacement is deliberately a DIFFERENT SIZE, because a renderbuffer cannot be + // sampled and so has no colour of its own to inherit: what a stale twin gets wrong is + // the STORAGE, and the storage's extent is the one property a readback can see. 8x8 + // green against a dead 4x4 red: fresh reads green everywhere, and a replacement that + // inherited the dead 4x4 driver renderbuffer either leaves the outer ring unwritten or + // makes the framebuffer incomplete - both of which this case reports as STALE. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, ARenderbufferAtARecycledAddressDoesNotInheritItsPredecessorsStorage) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + SkipUnlessTheObjectHandlePathIsAssertableHere("renderbuffer"); + if (IsSkipped()) return; + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + + GLuint deadRenderbuffer = 0; + glGenRenderbuffers(1, &deadRenderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, deadRenderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, + deadRenderbuffer); + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)); + for (int frame = 0; frame < kWarmupFrames; ++frame) { + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glViewport(0, 0, 4, 4); + ClearTo(1.0f, 0.0f, 0.0f, 1.0f); + BindDefaultFramebuffer(); + Gl().EndFrame(); + } + + // Detach BEFORE deleting: an attached renderbuffer is kept alive by the frontend + // FramebufferAttachmentObject's SharedPtr, so glDeleteRenderbuffers would only unname + // it and the object would not die here at all. + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, 0); + BindDefaultFramebuffer(); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glDeleteRenderbuffers(1, &deadRenderbuffer); + + GLuint liveRenderbuffer = 0; + glGenRenderbuffers(1, &liveRenderbuffer); + if (liveRenderbuffer != deadRenderbuffer) { + glDeleteRenderbuffers(1, &liveRenderbuffer); + glDeleteFramebuffers(1, &fbo); + GTEST_SKIP() << "inconclusive, not proven: glGenRenderbuffers returned " + << liveRenderbuffer << " rather than the deleted " << deadRenderbuffer + << ", so no ABA was constructed"; + } + RecordProperty("recycled_renderbuffer_name", static_cast(liveRenderbuffer)); + + glBindRenderbuffer(GL_RENDERBUFFER, liveRenderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 8, 8); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, + liveRenderbuffer); + const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + glViewport(0, 0, 8, 8); + ClearTo(0.0f, 1.0f, 0.0f, 1.0f); + const Image image = ReadPixels(8, 8); + BindDefaultFramebuffer(); + Gl().EndFrame(); + + const bool complete = status == GLenum(GL_FRAMEBUFFER_COMPLETE); + const bool allGreen = complete && static_cast(RegionIsMostly(image, 0, 8, 0, 8, + "green", 0.0, + "the replacement " + "renderbuffer")); + std::cout << "[ HandleRecycle ] arm=" << ArmName(m_arm) + << " renderbuffer observed=" << (allGreen ? "FRESH (8x8 all green)" + : "STALE (the replacement did not " + "get its own 8x8 storage)") + << " fbo_status=0x" << std::hex << status << std::dec << std::endl; + + if (ObjectAbaExpectation("renderbuffer") && m_arm == Arm::AbaControl) { + EXPECT_FALSE(allGreen) + << "[AbaControl expects the STALE renderbuffer] the replacement got its own 8x8 " + "storage with the identity half of the twin key deliberately defeated, so " + "the ABA was not reproduced and this control is controlling nothing."; + } else { + EXPECT_EQ(status, GLenum(GL_FRAMEBUFFER_COMPLETE)) + << "the framebuffer went incomplete after the recycled renderbuffer was given " + "8x8 storage, which is what a driver object inherited from the dead 4x4 " + "renderbuffer looks like"; + EXPECT_TRUE(RegionIsMostly(image, 0, 8, 0, 8, "green", 0.0, + "the replacement renderbuffer's own 8x8 storage")) + << "the replacement renderbuffer did not read back as its own storage, so the " + "clear reached a renderbuffer it only shares a name with"; + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glDeleteRenderbuffers(1, &liveRenderbuffer); + glDeleteFramebuffers(1, &fbo); + } + + // ------------------------------------------------------------------------------------ + // 5. The sampler object (SamplerCso). P4a content-addresses sampler CSOs at capacity 256 + // and hashes them field-wise over a canonical zero-initialised copy (D-F1), so two + // sampler objects with identical parameters are ONE CSO by design - which is why the + // ABA here is built out of two samplers whose parameters DIFFER. + // + // The observable is the BORDER COLOUR, sampled at a constant UV outside [0,1] with + // GL_CLAMP_TO_BORDER, so every fragment reads the border and the whole viewport is one + // colour. That is deliberate: the border colour is the sampler parameter with the + // fewest other paths to the driver (it is not in the texture's own parameter set the + // way a filter effectively is), and borderColorForm is the very field G7's second + // negative control drops. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, ASamplerAtARecycledAddressDoesNotInheritItsPredecessorsParameters) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + SkipUnlessTheObjectHandlePathIsAssertableHere("sampler object"); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + // The texture is BLUE and is never what the case reads: every fragment samples outside + // [0,1], so what comes back is the sampler's border colour and nothing else. + const GLuint texture = MakeSolidTexture(0, 0, 255); + + const auto makeBorderSampler = [](float r, float g, float b) { + GLuint sampler = 0; + glGenSamplers(1, &sampler); + const float border[4] = {r, g, b, 1.0f}; + 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); + glSamplerParameterfv(sampler, GL_TEXTURE_BORDER_COLOR, border); + return sampler; + }; + // Sampled at a constant UV well outside [0,1]: one colour for the whole viewport. + static const char* kBorderFS = R"(#version 330 core +uniform sampler2D uTex; +out vec4 oColor; +void main() { oColor = texture(uTex, vec2(4.0, 4.0)); } +)"; + std::string error; + const GLuint borderProgram = CompileProgram(kSampleVS, kBorderFS, &error); + ASSERT_NE(borderProgram, 0u) << error; + + const auto drawThroughSampler = [&](GLuint sampler) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(borderProgram); + glUniform1i(glGetUniformLocation(borderProgram, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glBindSampler(0, sampler); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + }; + + const GLuint redSampler = makeBorderSampler(1.0f, 0.0f, 0.0f); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first sampler left a GL error"; + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = drawThroughSampler(redSampler); + ExpectWholeViewportIs(warm, "red", "sampler warm-up frame " + std::to_string(frame)); + } + + glBindSampler(0, 0); + GLuint doomed = redSampler; + glDeleteSamplers(1, &doomed); + + const GLuint greenSampler = makeBorderSampler(0.0f, 1.0f, 0.0f); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement sampler left a GL error"; + if (greenSampler != redSampler) { + glDeleteSamplers(1, &greenSampler); + glDeleteTextures(1, const_cast(&texture)); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, const_cast(&buffer)); + glDeleteProgram(borderProgram); + GTEST_SKIP() << "inconclusive, not proven: glGenSamplers returned " << greenSampler + << " rather than the deleted " << redSampler + << ", so no ABA was constructed"; + } + RecordProperty("recycled_sampler_name", static_cast(greenSampler)); + + const Image image = drawThroughSampler(greenSampler); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/ObjectAbaExpectation("sampler object"), + image, "green", "red", + "the draw after the sampler object was recycled"); + + glBindSampler(0, 0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint cleanupSampler = greenSampler; + glDeleteSamplers(1, &cleanupSampler); + GLuint cleanupTexture = texture; + glDeleteTextures(1, &cleanupTexture); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + glDeleteProgram(borderProgram); + } + + // ------------------------------------------------------------------------------------ + // 6. The sampler VIEW (SamplerViewCso), which is a different question from either of the + // two above and is the reason it gets a case rather than a comment. + // + // A sampler view is the RESOLVED (texture, sampler) pair - what the unit actually + // samples - and P4a addresses it by IDENTITY per texture object (D-F2, the deviation + // from ARCHITECTURE.md:63's content-addressed 4096-entry cache, which is P7's). So the + // view's key names the texture; the case recycles the TEXTURE while an explicitly bound + // SAMPLER OBJECT stays alive and unchanged across the window, so what a stale view + // would inherit is the dead texture through a live sampler - which the texture case + // above cannot construct, because there the unit has no sampler object bound at all and + // the built-in sampler travels with the texture. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, ATextureAtARecycledAddressDoesNotInheritItsPredecessorsSamplerView) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + SkipUnlessTheObjectHandlePathIsAssertableHere("sampler view"); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + // One sampler object for the whole case: it is the half of the view's identity that + // must NOT move, so that a stale view can only come from the texture half. + 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_EDGE); + glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + + const auto drawSampled = [&](GLuint texture) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_sampleProgram); + glUniform1i(glGetUniformLocation(m_sampleProgram, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glBindSampler(0, sampler); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + }; + + const GLuint redTexture = MakeSolidTexture(255, 0, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the first texture left a GL error"; + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = drawSampled(redTexture); + ExpectWholeViewportIs(warm, "red", "sampler-view warm-up frame " + std::to_string(frame)); + } + + glBindSampler(0, 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint doomed = redTexture; + glDeleteTextures(1, &doomed); + + const GLuint greenTexture = MakeSolidTexture(0, 255, 0); + ASSERT_EQ(FirstGLError(), GLenum(GL_NO_ERROR)) << "building the replacement texture left a GL error"; + if (greenTexture != redTexture) { + GLuint cleanup = greenTexture; + glDeleteTextures(1, &cleanup); + glDeleteSamplers(1, &sampler); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, const_cast(&buffer)); + GTEST_SKIP() << "inconclusive, not proven: glGenTextures returned " << greenTexture + << " rather than the deleted " << redTexture + << ", so no ABA was constructed for the sampler view"; + } + RecordProperty("recycled_sampler_view_texture_name", static_cast(greenTexture)); + + const Image image = drawSampled(greenTexture); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/ObjectAbaExpectation("sampler view"), + image, "green", "red", + "the draw after the sampler view's texture was recycled under a live " + "sampler object"); + + glBindSampler(0, 0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint cleanupTexture = greenTexture; + glDeleteTextures(1, &cleanupTexture); + glDeleteSamplers(1, &sampler); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + // ------------------------------------------------------------------------------------ + // 7. The program (ShaderCso). Espryt reaches a program's backend twin through + // g_programTwinLookupMemo (DirectGLES.cpp:134-135), which is keyed on the frontend + // ProgramObject; P4a re-keys it on the shader CSO's {slot, gen} and gives the program a + // create_shader_state / delete_shader_state lifetime of its own. + // + // A program's colour is BAKED INTO ITS SOURCE here rather than passed as a uniform, so + // "the draw used the dead program" is the only thing the pixels can mean: a uniform + // would be re-set on the replacement and would hide exactly the inheritance the case is + // about. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, AProgramAtARecycledAddressDoesNotInheritItsPredecessorsShaderCso) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + SkipUnlessTheObjectHandlePathIsAssertableHere("program"); + if (IsSkipped()) return; + + static const char* kRedFS = R"(#version 330 core +out vec4 oColor; +void main() { oColor = vec4(1.0, 0.0, 0.0, 1.0); } +)"; + static const char* kGreenFS = R"(#version 330 core +out vec4 oColor; +void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + const auto drawWith = [&](GLuint program) { + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(program); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + return image; + }; + + std::string error; + const GLuint redProgram = CompileProgram(kColorVS, kRedFS, &error); + ASSERT_NE(redProgram, 0u) << error; + for (int frame = 0; frame < kWarmupFrames; ++frame) { + const Image warm = drawWith(redProgram); + ExpectWholeViewportIs(warm, "red", "program warm-up frame " + std::to_string(frame)); + } + + // Unbind first: a program that is still current is kept alive by the frontend, so + // glDeleteProgram would only flag it and the object would not die here. + glUseProgram(0); + GLuint doomed = redProgram; + glDeleteProgram(doomed); + + const GLuint greenProgram = CompileProgram(kColorVS, kGreenFS, &error); + ASSERT_NE(greenProgram, 0u) << error; + if (greenProgram != redProgram) { + glDeleteProgram(greenProgram); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + glDeleteBuffers(1, const_cast(&buffer)); + GTEST_SKIP() << "inconclusive, not proven: glCreateProgram returned " << greenProgram + << " rather than the deleted " << redProgram + << ", so no ABA was constructed"; + } + RecordProperty("recycled_program_name", static_cast(greenProgram)); + + const Image image = drawWith(greenProgram); + ExpectPixelsFor(m_arm, /*armExpectsCorruption=*/ObjectAbaExpectation("program"), image, + "green", "red", "the draw after the program was recycled"); + + glUseProgram(0); + glDeleteProgram(greenProgram); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + // ==================================================================================== + // G8b: one leak case per kind P4a mints. See AssertChurnReturnsEverySlot for the shape and + // for why a kind that was never minted SKIPS rather than passing. + // ==================================================================================== + + TEST_F(HandleRecycleScenario, DestroyedTexturesReturnTheirTextureSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + AssertChurnReturnsEverySlot( + PipeSlotKind::Texture, SlotSpace::Ordinary, "Texture", "B (clientfb)", + /*maxInFlight=*/1u, + [&](bool checkPixels, const std::function& observe) { + const GLuint texture = MakeSolidTexture(0, 255, 0); + const Image image = DrawTexturedQuadAndRead(vao, texture); + if (checkPixels) { + // One picture check, so a green here cannot mean "the draws never happened + // and therefore nothing was ever minted". + ExpectWholeViewportIs(image, "green", "the first churned texture's draw"); + } + observe(); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint doomed = texture; + glDeleteTextures(1, &doomed); + }); + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + TEST_F(HandleRecycleScenario, DestroyedTexturesReturnTheirSamplerViewSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + // A sampler view is identity-addressed PER TEXTURE OBJECT (D-F2), so the churn that + // exercises it is a texture churn - and the view's slot is released by the TEXTURE's + // death helper (D-I1: the texture helper emits ResourceDestroy, DeleteSamplerView and + // DeleteSamplerState), which is precisely why it needs a case of its own: a helper + // that forgot one of its three frees leaks only that kind and nothing else moves. + AssertChurnReturnsEverySlot( + PipeSlotKind::SamplerViewCso, SlotSpace::Ordinary, "SamplerViewCso", "C (clientsp)", + /*maxInFlight=*/1u, + [&](bool checkPixels, const std::function& observe) { + const GLuint texture = MakeSolidTexture(0, 255, 0); + const Image image = DrawTexturedQuadAndRead(vao, texture); + if (checkPixels) { + ExpectWholeViewportIs(image, "green", "the first churned sampler view's draw"); + } + observe(); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, 0); + GLuint doomed = texture; + glDeleteTextures(1, &doomed); + }); + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + TEST_F(HandleRecycleScenario, DestroyedRenderbuffersReturnTheirRenderbufferSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + + AssertChurnReturnsEverySlot( + PipeSlotKind::Renderbuffer, SlotSpace::Ordinary, "Renderbuffer", "B (clientfb)", + /*maxInFlight=*/1u, + [&](bool checkPixels, const std::function& observe) { + GLuint renderbuffer = 0; + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 4, 4); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, + renderbuffer); + glViewport(0, 0, 4, 4); + ClearTo(0.0f, 1.0f, 0.0f, 1.0f); + if (checkPixels) { + const Image image = ReadPixels(4, 4); + ExpectWholeViewportIs(image, "green", "the first churned renderbuffer's clear"); + } + observe(); + // Detach before deleting: an attached renderbuffer is kept alive by the + // frontend attachment's SharedPtr and would not die inside the round. + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, 0); + BindDefaultFramebuffer(); + Gl().EndFrame(); + glBindRenderbuffer(GL_RENDERBUFFER, 0); + glDeleteRenderbuffers(1, &renderbuffer); + }); + + glDeleteFramebuffers(1, &fbo); + } + + TEST_F(HandleRecycleScenario, DestroyedFramebuffersReturnTheirFramebufferSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + // One attachment for the whole case: only the framebuffer churns, so a texture slot + // that failed to come back cannot be scored against this kind. + GLuint attachment = 0; + glGenTextures(1, &attachment); + glBindTexture(GL_TEXTURE_2D, attachment); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, 4, 4, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + + // A FRAMEBUFFER HAS NO WIRE LIFETIME AT ALL (D-I2): no create_*, no destroy row, and a + // death helper that raises the notice and frees the slot and emits nothing. The + // allocator is therefore the only observable this kind has, which makes this case the + // whole of its lifetime coverage rather than a supplement to a wire assertion. + AssertChurnReturnsEverySlot( + PipeSlotKind::Framebuffer, SlotSpace::Ordinary, "Framebuffer", "B (clientfb)", + /*maxInFlight=*/1u, + [&](bool checkPixels, const std::function& observe) { + GLuint fbo = 0; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, + attachment, 0); + glViewport(0, 0, 4, 4); + ClearTo(0.0f, 1.0f, 0.0f, 1.0f); + if (checkPixels) { + const Image image = ReadPixels(4, 4); + ExpectWholeViewportIs(image, "green", "the first churned framebuffer's clear"); + } + observe(); + BindDefaultFramebuffer(); + Gl().EndFrame(); + glDeleteFramebuffers(1, &fbo); + }); + + glDeleteTextures(1, &attachment); + } + + TEST_F(HandleRecycleScenario, DestroyedSamplersReturnTheirSamplerCsoSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + const GLuint texture = MakeSolidTexture(0, 255, 0); + + // EVERY ROUND'S SAMPLER CARRIES A DIFFERENT PARAMETER SET, and that is not decoration: + // sampler CSOs are CONTENT-addressed at capacity 256 (D-F1), so 48 identical samplers + // would legitimately be ONE CSO and the case would assert nothing about the death + // path. The LOD bias moves per round, which changes the hash and nothing else. + // + // ...AND THE SET IS FINITE ON PURPOSE, which is the other half of the same argument. + // The bias cycles through kDistinctSamplerContents values, so the churn walks a + // CLOSED content space; the warm-up below walks all of it once before the baseline is + // taken, and every measured round then asks the cache for an entry it already holds. + // Without that, the case measured the cache filling up - one retained slot per + // distinct parameter block, which is C's design (ID-17) and not a death-path defect - + // and reported it as P3a's C-1 leak. Measured on the tree where C landed: 48 rounds, + // 14 slots retained, i.e. the distinct contents minus the two the warm-up had already + // interned. With the whole space warm the same churn must move nothing at all. + constexpr int kDistinctSamplerContents = 16; + int round = 0; + AssertChurnReturnsEverySlot( + PipeSlotKind::SamplerCso, SlotSpace::Ordinary, "SamplerCso", "C (clientsp)", + /*maxInFlight=*/1u, + [&](bool checkPixels, const std::function& observe) { + 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_EDGE); + glSamplerParameteri(sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glSamplerParameterf(sampler, GL_TEXTURE_MIN_LOD, + -static_cast(round % 16)); + ++round; + + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(m_sampleProgram); + glUniform1i(glGetUniformLocation(m_sampleProgram, "uTex"), 0); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glBindSampler(0, sampler); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + if (checkPixels) { + ExpectWholeViewportIs(image, "green", "the first churned sampler's draw"); + } + observe(); + glBindSampler(0, 0); + glDeleteSamplers(1, &sampler); + }, + /*warmUpRounds=*/static_cast(kDistinctSamplerContents) + 2u); + + glBindTexture(GL_TEXTURE_2D, 0); + GLuint cleanupTexture = texture; + glDeleteTextures(1, &cleanupTexture); + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + TEST_F(HandleRecycleScenario, DestroyedProgramsReturnTheirShaderCsoSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + // A DIFFERENT SOURCE PER ROUND, for D-F1's reason applied to programs: a shader CSO is + // keyed on (ShaderCso, Version) and a program archive is content-addressed, so 48 + // identical programs could legitimately be one CSO. The constant in the fragment + // shader moves per round. + int round = 0; + AssertChurnReturnsEverySlot( + PipeSlotKind::ShaderCso, SlotSpace::Ordinary, "ShaderCso", "C (clientsp)", + /*maxInFlight=*/1u, + [&](bool checkPixels, const std::function& observe) { + const std::string fs = "#version 330 core\nout vec4 oColor;\nvoid main() { " + "oColor = vec4(0.0, 1.0, 0.0, 1.0) + vec4(" + + std::to_string(round) + ".0 * 0.0); }\n"; + ++round; + std::string error; + const GLuint program = CompileProgram(kColorVS, fs.c_str(), &error); + ASSERT_NE(program, 0u) << error; + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(program); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + if (checkPixels) { + ExpectWholeViewportIs(image, "green", "the first churned program's draw"); + } + observe(); + glUseProgram(0); + glDeleteProgram(program); + }); + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + // ------------------------------------------------------------------------------------ + // The COMPOSITE's own leak case (G8b, D-H7). It is a separate case and not a second phase + // of the one above for the reason the band exists at all: a composite's ShaderCso slot + // comes out of the reserved high band (kMGPipeShaderCsoCompositeSlotBase = 983040) through + // the allocator's ONE door into it, AllocateComposite, and it is released by TWO + // independent paths - ProgramPipelineObject::GetCachedDrawProgram's LRU eviction and the + // composite ProgramObject's own destructor. The second free is a no-op only because Free + // refuses a slot that is not live at that generation (SlotAllocator.cpp:117-119); if it + // ever stopped being one, this is where a double free or a never-freed band slot shows up, + // and nowhere else - the band is sparse against the ordinary program space by design, so + // an ordinary program's leak case cannot see it. + // ------------------------------------------------------------------------------------ + TEST_F(HandleRecycleScenario, EvictedPipelineCompositesReturnTheirShaderCsoSlots) { + if (!Ready()) return; + SkipUnlessTheArmIsAssertableHere(); + if (IsSkipped()) return; + + const GLuint buffer = MakeQuadBuffer(1.0f, 1.0f, 1.0f); + GLuint vao = 0; + glGenVertexArrays(1, &vao); + ConfigureQuadVao(vao, buffer); + + static const char* kSeparableVS = R"(#version 410 core +in vec2 aPos; +in vec3 aColor; +out gl_PerVertex { vec4 gl_Position; }; +out vec3 vColor; +void main() { + vColor = aColor; + gl_Position = vec4(aPos, 0.0, 1.0); +} +)"; + int round = 0; + AssertChurnReturnsEverySlot( + PipeSlotKind::ShaderCso, + // THE BAND'S OWN COUNTERS, not the ordinary ShaderCso space's (review F-M4, + // contract-v2.md 4.3/7.6). A round creates THREE ShaderCsos - the two stage + // programs and the composite the pipeline flattens them into - and the two stage + // programs are ORDINARY slots. So the ordinary space moves in this case whether + // or not a composite ever comes back, the "nothing was ever minted" skip would + // not fire, and every assertion below would have been a statement about the two + // stage programs while the band - the double-free-refusal case the band exists to + // police - went unread. + SlotSpace::CompositeBand, "ShaderCso (pipeline composites)", "C (clientsp)", + // ONE in flight, because in the BAND a round holds exactly one slot: the + // composite. (Against the ordinary space the answer would have been three.) + /*maxInFlight=*/1u, + [&](bool checkPixels, const std::function& observe) { + // A DIFFERENT FRAGMENT STAGE PER ROUND: the composite is keyed on + // ProgramPipelineObject::ComputeDrawProgramSignature(), the per-stage + // {lifetimeId, GetLinkVersion()} array, so a round that rebuilt an identical + // pipeline out of the same two programs would legitimately reuse one composite + // and the case would assert nothing about the release paths. + const std::string fsSource = + "#version 410 core\nin vec3 vColor;\nout vec4 oColor;\nvoid main() { oColor " + "= vec4(vColor, 1.0) + vec4(" + std::to_string(round) + ".0 * 0.0); }\n"; + ++round; + const char* vsSource = kSeparableVS; + const char* fsSourcePtr = fsSource.c_str(); + const GLuint vs = glCreateShaderProgramv(GL_VERTEX_SHADER, 1, &vsSource); + const GLuint fs = glCreateShaderProgramv(GL_FRAGMENT_SHADER, 1, &fsSourcePtr); + GLuint pipeline = 0; + glGenProgramPipelines(1, &pipeline); + glUseProgramStages(pipeline, GL_VERTEX_SHADER_BIT, vs); + glUseProgramStages(pipeline, GL_FRAGMENT_SHADER_BIT, fs); + + BindDefaultFramebuffer(); + ClearTo(0.0f, 0.0f, 0.0f, 1.0f); + glUseProgram(0); + glBindProgramPipeline(pipeline); + glBindVertexArray(vao); + glDrawArrays(GL_TRIANGLES, 0, kVertexCount); + const Image image = ReadPixels(Gl().Width(), Gl().Height()); + Gl().EndFrame(); + if (checkPixels) { + // The draw has to actually happen: GetProgramForDraw() flattens the + // pipeline into a composite at the validate point and nowhere else, so a + // round whose draw was dropped mints no composite and the case would be + // measuring an empty churn. + ExpectWholeViewportIs(image, "white", "the first churned composite's draw"); + } + observe(); + glBindProgramPipeline(0); + glDeleteProgramPipelines(1, &pipeline); + glDeleteProgram(vs); + glDeleteProgram(fs); + }); + + glBindVertexArray(0); + glDeleteVertexArrays(1, &vao); + GLuint cleanupBuffer = buffer; + glDeleteBuffers(1, &cleanupBuffer); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/IndexedDrawFamilyScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/IndexedDrawFamilyScenario.cpp new file mode 100644 index 000000000..3d4dcb45f --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/IndexedDrawFamilyScenario.cpp @@ -0,0 +1,313 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/IndexedDrawFamilyScenario.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 DRAW FAMILY P5b's d1 PUTS ON THE WIRE (MG_Remote/CONTRACT-P5B.md §2 d1): one +// VBO, one EBO, one program, one VAO, and one picture per draw entry point whose correctness +// DEPENDS on the fields that entry point adds to draw_vbo. Two quads live in the vertex buffer, +// a left one and a right one, and every case draws exactly one of them or both through a +// different entry point: +// +// glDrawElements the element-buffer offset (Start = offset / IndexSize) +// glDrawElements, no EBO bound a CLIENT index array - the kDrawHasUserIndices span the +// client stages into SEG_STAGE (the P8 resolve-on-client +// rule, applied by d1) +// glDrawElementsBaseVertex IndexBias: the same six indices land on the other quad +// glDrawRangeElements kDrawHasIndexRange with MinIndex / MaxIndex +// glDrawElementsInstancedBaseVertex InstanceCount: the Minecraft trace's own slot +// (improved-transparency-minecraft-26.3 first-stops here) +// glMultiDrawElementsBaseVertex NumDraws = 2 with a per-range base vertex +// glMultiDrawArrays NumDraws = 2, arrays +// glMultiDrawElementsIndirect kDrawIsIndirect, the MGPDrawIndirect second tail +// +// It is an ORDINARY GL scenario and runs in every lane; the DirectGLES.Split. entries run the +// same bodies under MOBILEGL_TRANSPORT=inproc, where a field that did not cross is a wrong +// picture (the other quad, or no quad) rather than a green. The harness destructor's emit-seq +// check (ScenarioFixture.h) is the statement that records crossed at all; the boxes below are +// the statement that the RIGHT fields crossed. +// +// No glFlush anywhere, for TriangleScenario's reason (its header, point 2): glReadPixels is the +// ordering point on both backends and the SEG_REPLY round trip under split. + +#include +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + // gl_InstanceID shifts a quad one full quad-width to the right per instance, so the + // second instance of the LEFT quad lands exactly on the RIGHT quad's box: an instance + // count that did not cross draws one quad, one that did draws two. + constexpr const char* kVertexSource = R"(#version 330 core +layout(location = 0) in vec2 aPos; +void main() { + gl_Position = vec4(aPos.x + float(gl_InstanceID), aPos.y, 0.0, 1.0); +} +)"; + + constexpr const char* kFragmentSource = R"(#version 330 core +out vec4 oColor; +void main() { oColor = vec4(0.0, 1.0, 0.0, 1.0); } +)"; + + struct Vertex { + float x, y; + }; + + // Vertices 0..3: the LEFT quad's corners; 4..7: the RIGHT quad's corners (for the + // indexed draws). Vertices 8..13 and 14..19: the same two quads as six vertices each + // (for the arrays draws). x spans [-0.9, -0.1] and [0.1, 0.9]; y spans [-0.8, 0.8]. + constexpr Vertex kVertices[20] = { + {-0.9f, -0.8f}, {-0.1f, -0.8f}, {-0.1f, 0.8f}, {-0.9f, 0.8f}, // 0..3 left + {0.1f, -0.8f}, {0.9f, -0.8f}, {0.9f, 0.8f}, {0.1f, 0.8f}, // 4..7 right + {-0.9f, -0.8f}, {-0.1f, -0.8f}, {-0.1f, 0.8f}, // 8..13 left, arrays + {-0.1f, 0.8f}, {-0.9f, 0.8f}, {-0.9f, -0.8f}, + {0.1f, -0.8f}, {0.9f, -0.8f}, {0.9f, 0.8f}, // 14..19 right, arrays + {0.9f, 0.8f}, {0.1f, 0.8f}, {0.1f, -0.8f}, + }; + + // Indices 0..5 draw the left quad; 6..11 draw the right one. GL_UNSIGNED_SHORT, so the + // second run starts at BYTE offset 12 and Start = 6 on the wire. + constexpr std::uint16_t kIndices[12] = {0, 1, 2, 2, 3, 0, 4, 5, 6, 6, 7, 4}; + constexpr GLsizeiptr kRightRunByteOffset = 6 * sizeof(std::uint16_t); + + struct DrawElementsIndirectCommand { + std::uint32_t count, instanceCount, firstIndex, baseVertex, baseInstance; + }; + + class IndexedDrawFamilyScenario : public ScenarioTest { + protected: + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + + std::string error; + m_program = CompileProgram(kVertexSource, kFragmentSource, &error); + ASSERT_NE(m_program, 0u) << error; + + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, GLsizeiptr(sizeof(kVertices)), kVertices, GL_STATIC_DRAW); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), nullptr); + glEnableVertexAttribArray(0); + glGenBuffers(1, &m_ebo); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(sizeof(kIndices)), kIndices, GL_STATIC_DRAW); + ASSERT_EQ(FirstGLError(), 0u) << "building the VBO, the EBO and the VAO"; + } + + void TearDown() override { + if (!Ready() || IsSkipped()) return; + glBindVertexArray(0); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + glBindBuffer(GL_DRAW_INDIRECT_BUFFER, 0); + if (m_indirect != 0) glDeleteBuffers(1, &m_indirect); + if (m_ebo != 0) glDeleteBuffers(1, &m_ebo); + if (m_vbo != 0) glDeleteBuffers(1, &m_vbo); + if (m_vao != 0) glDeleteVertexArrays(1, &m_vao); + if (m_program != 0) glDeleteProgram(m_program); + m_indirect = m_ebo = m_vbo = m_vao = m_program = 0; + } + + // Clear to blue, run `draw`, read back. The draw is the ONLY thing that differs + // between the cases. + template + Image ClearThenDrawThenRead(Draw draw) { + HeadlessGL& gl = Gl(); + BindDefaultFramebuffer(); + glViewport(0, 0, gl.Width(), gl.Height()); + glDisable(GL_SCISSOR_TEST); + glDisable(GL_DEPTH_TEST); + ClearTo(0.0f, 0.0f, 1.0f, 1.0f); + glUseProgram(m_program); + glBindVertexArray(m_vao); + draw(); + return ReadPixels(gl.Width(), gl.Height()); + } + + // The interior of the left quad, of the right quad, and the bottom-left corner + // that no quad covers (it carries the clear). + void ExpectLeft(const Image& image, const char* color, const std::string& when) { + const int w = image.Width(); + const int h = image.Height(); + EXPECT_TRUE(RegionIsMostly(image, (w * 20) / 100, (w * 30) / 100, (h * 40) / 100, + (h * 60) / 100, color, 0.0, when + " (left quad)")); + } + void ExpectRight(const Image& image, const char* color, const std::string& when) { + const int w = image.Width(); + const int h = image.Height(); + EXPECT_TRUE(RegionIsMostly(image, (w * 70) / 100, (w * 80) / 100, (h * 40) / 100, + (h * 60) / 100, color, 0.0, when + " (right quad)")); + } + void ExpectCorner(const Image& image, const char* color, const std::string& when) { + const int w = image.Width(); + const int h = image.Height(); + EXPECT_TRUE(RegionIsMostly(image, 0, (w * 3) / 100, 0, (h * 3) / 100, color, 0.0, + when + " (corner, the clear)")); + } + + unsigned int m_program = 0; + unsigned int m_vao = 0; + unsigned int m_vbo = 0; + unsigned int m_ebo = 0; + unsigned int m_indirect = 0; + }; + + } // namespace + + // The census's own first blocker for every Minecraft trace: an element-buffer glDrawElements. + // The byte offset selects the RIGHT quad, so an offset that crossed as 0 (or not at all) + // paints the left one. + TEST_F(IndexedDrawFamilyScenario, AnElementBufferDrawElementsOffsetSelectsTheRightQuad) { + if (!Ready() || IsSkipped()) return; + const Image image = ClearThenDrawThenRead([] { + glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, + reinterpret_cast(kRightRunByteOffset)); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectRight(image, "green", "glDrawElements at byte offset 12"); + ExpectLeft(image, "blue", "glDrawElements at byte offset 12 must not paint the left quad"); + ExpectCorner(image, "blue", "glDrawElements"); + // The frame boundary, once, so the lane reaches Present as TriangleScenario does. + Gl().EndFrame(); + } + + // No element buffer bound: `indices` is the application's own array. Under split the client + // stages the twelve bytes and the record names the run (kDrawHasUserIndices); the server + // resolves it for the call only. The array names the RIGHT quad's vertices directly. + TEST_F(IndexedDrawFamilyScenario, AClientIndexArrayIsStagedAndDrawsTheQuadItNames) { + if (!Ready() || IsSkipped()) return; + static const std::uint16_t kClientIndices[6] = {4, 5, 6, 6, 7, 4}; + const Image image = ClearThenDrawThenRead([] { + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); // the VAO's element slot, emptied + glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, kClientIndices); + }); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo); + EXPECT_EQ(FirstGLError(), 0u); + ExpectRight(image, "green", "glDrawElements from a client index array"); + ExpectLeft(image, "blue", "a client index array naming vertices 4..7 must not paint the left quad"); + ExpectCorner(image, "blue", "client index array"); + } + + // The same six indices as the left quad, plus a base vertex of 4: IndexBias is what moves + // the picture to the right quad. + TEST_F(IndexedDrawFamilyScenario, DrawElementsBaseVertexMovesTheSameIndicesToTheOtherQuad) { + if (!Ready() || IsSkipped()) return; + const Image image = ClearThenDrawThenRead([] { + glDrawElementsBaseVertex(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, nullptr, 4); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectRight(image, "green", "glDrawElementsBaseVertex(basevertex = 4)"); + ExpectLeft(image, "blue", "a base vertex of 4 must not paint the left quad"); + } + + // kDrawHasIndexRange: the ranged form with the right quad's index range and byte offset. + TEST_F(IndexedDrawFamilyScenario, DrawRangeElementsDrawsTheRangedRun) { + if (!Ready() || IsSkipped()) return; + const Image image = ClearThenDrawThenRead([] { + glDrawRangeElements(GL_TRIANGLES, 4, 7, 6, GL_UNSIGNED_SHORT, + reinterpret_cast(kRightRunByteOffset)); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectRight(image, "green", "glDrawRangeElements(4..7) at byte offset 12"); + ExpectLeft(image, "blue", "glDrawRangeElements must not paint the left quad"); + } + + // The Minecraft trace's own entry point (improved-transparency-minecraft-26.3 first-stops at + // DrawElementsInstancedBaseVertex). Two instances of the LEFT quad: gl_InstanceID shifts the + // second onto the right box, so an InstanceCount that did not cross paints one quad. + TEST_F(IndexedDrawFamilyScenario, DrawElementsInstancedBaseVertexPaintsOneQuadPerInstance) { + if (!Ready() || IsSkipped()) return; + const Image two = ClearThenDrawThenRead([] { + glDrawElementsInstancedBaseVertex(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, nullptr, 2, 0); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectLeft(two, "green", "instance 0 of the left quad"); + ExpectRight(two, "green", "instance 1 of the left quad, shifted by gl_InstanceID"); + ExpectCorner(two, "blue", "instanced draw"); + + const Image one = ClearThenDrawThenRead([] { + glDrawElementsInstancedBaseVertex(GL_TRIANGLES, 6, GL_UNSIGNED_SHORT, nullptr, 1, 0); + }); + ExpectLeft(one, "green", "one instance"); + ExpectRight(one, "blue", "one instance must not paint the right quad"); + } + + // NumDraws = 2 with a per-range base vertex (the census's MultiDrawElementsBaseVertex, 18 + // entries): both quads from the same six indices. + TEST_F(IndexedDrawFamilyScenario, MultiDrawElementsBaseVertexPaintsEverySubDraw) { + if (!Ready() || IsSkipped()) return; + const Image image = ClearThenDrawThenRead([] { + const GLsizei counts[2] = {6, 6}; + const void* offsets[2] = {nullptr, nullptr}; + const GLint baseVertices[2] = {0, 4}; + glMultiDrawElementsBaseVertex(GL_TRIANGLES, counts, GL_UNSIGNED_SHORT, offsets, 2, baseVertices); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectLeft(image, "green", "sub-draw 0 (base vertex 0)"); + ExpectRight(image, "green", "sub-draw 1 (base vertex 4)"); + ExpectCorner(image, "blue", "multi-draw"); + } + + // NumDraws = 2, arrays: the six-vertex copies of both quads. + TEST_F(IndexedDrawFamilyScenario, MultiDrawArraysPaintsEverySubDraw) { + if (!Ready() || IsSkipped()) return; + const Image image = ClearThenDrawThenRead([] { + const GLint firsts[2] = {8, 14}; + const GLsizei counts[2] = {6, 6}; + glMultiDrawArrays(GL_TRIANGLES, firsts, counts, 2); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectLeft(image, "green", "sub-draw 0 (first 8)"); + ExpectRight(image, "green", "sub-draw 1 (first 14)"); + ExpectCorner(image, "blue", "multi-draw arrays"); + } + + // kDrawIsIndirect: two DrawElementsIndirectCommands in a GL_DRAW_INDIRECT_BUFFER, the second + // at firstIndex 6. The record carries the buffer's handle, the byte offset and the count; + // the server reads the commands from ITS copy of the buffer and never from a host pointer. + TEST_F(IndexedDrawFamilyScenario, MultiDrawElementsIndirectDrawsFromTheIndirectBuffer) { + if (!Ready() || IsSkipped()) return; + const DrawElementsIndirectCommand commands[2] = {{6, 1, 0, 0, 0}, {6, 1, 6, 0, 0}}; + glGenBuffers(1, &m_indirect); + glBindBuffer(GL_DRAW_INDIRECT_BUFFER, m_indirect); + glBufferData(GL_DRAW_INDIRECT_BUFFER, GLsizeiptr(sizeof(commands)), commands, GL_STATIC_DRAW); + ASSERT_EQ(FirstGLError(), 0u) << "building the indirect buffer"; + const Image both = ClearThenDrawThenRead([] { + glMultiDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, nullptr, 2, 0); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectLeft(both, "green", "indirect command 0"); + ExpectRight(both, "green", "indirect command 1 (firstIndex 6)"); + ExpectCorner(both, "blue", "indirect draw"); + + // The second command alone, by byte offset: Offset on the wire is the call's own. + const Image second = ClearThenDrawThenRead([] { + glDrawElementsIndirect(GL_TRIANGLES, GL_UNSIGNED_SHORT, + reinterpret_cast(sizeof(DrawElementsIndirectCommand))); + }); + EXPECT_EQ(FirstGLError(), 0u); + ExpectRight(second, "green", "glDrawElementsIndirect at byte offset 20"); + ExpectLeft(second, "blue", "the second command alone must not paint the left quad"); + } + +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp index 10bee4bac..d736591e6 100644 --- a/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp +++ b/MobileGL/MG_IntegrationTest/Scenarios/LargeArenaAdoptionScenario.cpp @@ -25,13 +25,31 @@ // * GetBufferSubData reads back the latest CPU write - the shadow IS the map; // * a compute-shader write through an SSBO binding of the same arena is read // back - the GPU-written path for adopted stores (glFinish + direct read). +// +// P3a (gate G10, G12) adds a fourth case and two more lanes, and neither of them +// changes what the three above assert: +// +// * AnAdoptionCostsExactlyOneMapPersistentRoundtrip counts the acquisition. +// ARCHITECTURE.md:474 prices the adopted store at one round trip per STORAGE +// DEFINITION; `map-persistent-roundtrips` counts every map_persistent +// emission, mint or decline (D-B2), so one definition plus a frame of draws +// must publish exactly one. It reads the library's summary line, so it needs +// a lane with the stats channel and a private log path, and it SKIPS - with +// the reason - anywhere else and on any tree that does not emit the counter. +// * the three original cases are registered TWICE MORE, with P3a's resource and +// vertex-input subsystem bits set and cleared, because this file is where an +// adopted store's whole life is exercised: definition, in-flight SubData, +// readback and a GPU write. If the handle path and the legacy BufferBackendOps +// path disagree about any of it, one of the two arms goes red here. #include +#include #include #include #include #include "../Harness/HeadlessGL.h" +#include "../Harness/PipeStatsWindow.h" #include "../Harness/ScenarioFixture.h" #ifdef GLAPI @@ -73,6 +91,19 @@ layout(std430, binding = 0) buffer Arena { uint word; }; void main() { word = 0xC0FFEEu; } )"; + // Set by the MapPersistentRoundtrips. ctest entry and by nothing else; a harness marker, + // never read by the library. + constexpr const char* kLaneMarker = "MGITEST_MPR_LANE"; + // Draws issued against the arena inside the counted window. One definition, many draws: + // "one per definition" (1) and "one per draw" (kDrawsInTheWindow) have to be different + // numbers or the assertion cannot tell them apart. + constexpr int kDrawsInTheWindow = 5; + + bool BuildMarkerIsSet(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] == '1' && value[1] == '\0'; + } + struct Vertex { float x, y; float r, g, b; @@ -188,6 +219,40 @@ void main() { word = 0xC0FFEEu; } glDrawArrays(GL_TRIANGLES, 0, 6); } + // GTEST_SKIP() returns from the function it is written in, so this cannot report + // through a return value; the caller pairs it with `if (IsSkipped()) return;`. + void SkipUnlessTheRoundtripCounterIsReadableHere() { + if (std::getenv(kLaneMarker) == nullptr) { + GTEST_SKIP() << "runs only in its own lane: the MapPersistentRoundtrips. ctest entry " + "sets MGITEST_MPR_LANE together with MOBILEGL_PIPE_PUSH's P3a mask, " + "MOBILEGL_PIPE_STATS=1, MOBILEGL_PIPE_STATS_PERIOD=1 and a private " + "MOBILEGL_LOG_FILE_PATH. The ambient entries and the two subsystem " + "arms configure none of that, and their log is shared - a read there " + "would race a neighbour's bring-up."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_PUSH_BUILD")) { + GTEST_SKIP() << "this library was built without MOBILEGL_PIPE_PUSH, so " + "CallClass::MapPersistentRoundtrips does not exist and the summary " + "line carries no mpr=. The entry stays registered so that " + "`ctest -L integration-gpu` names the same tests in both builds (G2)."; + return; + } + if (!BuildMarkerIsSet("MGITEST_PIPE_RESOURCE_EMITTER_PRESENT")) { + GTEST_SKIP() << "subsystem not implemented on this tree: no source under " + "MobileGL/MG_Impl/Pipe/ names MapPersistentRoundtrips, so nothing " + "emits map_persistent and mpr= is structurally zero. P3a package B " + "owns that emitter; this entry arms itself when it lands."; + return; + } + if (PipeStatsWindow::LibraryLogPath().empty()) { + GTEST_SKIP() << "the lane configured no MOBILEGL_LOG_FILE_PATH, and the library's " + "summary line is the only channel this module has for reading " + "PipeStats"; + return; + } + } + std::array CenterPixel() { std::array px = {0, 0, 0, 0}; glReadPixels(Gl().Width() / 2, Gl().Height() / 2, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, @@ -372,4 +437,72 @@ void main() { word = 0xC0FFEEu; } << "the compute write into the adopted arena did not reach the CPU readback"; } + // G10, the per-adoption half: ONE storage definition of an arena costs ONE map_persistent + // emission, however many draws read it afterwards. + // + // The arena SetUp defined is deliberately re-defined inside the counted window rather than + // measured from outside it: the window a summary line reports is "since the previous line", + // so the definition has to happen between the two swaps that bracket it, and a case that + // counted SetUp's definition would be reading a window it did not control. + // + // ONE reading case per lane, for the reason PipeStatsWindow.h gives: the library truncates the + // log per process, so two readers in a lane race under `ctest -j`. + TEST_F(LargeArenaAdoptionScenario, AnAdoptionCostsExactlyOneMapPersistentRoundtrip) { + if (!Ready() || IsSkipped()) return; + SkipUnlessTheRoundtripCounterIsReadableHere(); + if (IsSkipped()) return; + + Gl().EndFrame(); // close the setup window, SetUp's own definition included + + // One definition of a store past the 16 MiB adoption threshold, taken by RE-SPECIFYING + // SetUp's arena while m_vao's attributes are still pointing into it - and the attributes + // are deliberately NOT re-declared afterwards, so the draws below can only land if the + // backend VAO followed the new store on its own. + // + // That is the hard shape on purpose. It was routed around in the first cut of this file + // because feat/disaggregated did not yet carry `dev`'s d7655247 ("rebind VAOs when an + // adopted buffer is respecified - the immediate retire path forgot the buffer-id + // generation") and the workload was a hard SIGSEGV inside the vertex fetch on the first + // draw after the re-specification. ID-9 merged that fix (feat/disaggregated 5cb826b0) and + // requires it to hold in BOTH the legacy and the handle arm of the respecify/retire path, + // so this workload counts the path rather than avoiding it: under the + // ResourceSubsystemOn./Off. lanes the same body runs on both arms, and a handle arm that + // re-implemented the retire without the rebind is a crash here rather than a silent + // divergence found on device. + glBindBuffer(GL_ARRAY_BUFFER, m_arena); + glBufferData(GL_ARRAY_BUFFER, kArenaBytes, nullptr, GL_DYNAMIC_DRAW); + ASSERT_EQ(FirstGLError(), 0u) << "re-specifying the arena inside the counted window failed"; + + // ... and then a frame's worth of traffic against it, of the shape the arena exists for: + // a SubData per draw, every one of which lands in the adopted mapping and none of which + // may acquire it again. + for (int draw = 0; draw < kDrawsInTheWindow; ++draw) { + UploadQuad(0.f, 1.f, 0.f); + DrawQuad(); + } + const auto px = CenterPixel(); + EXPECT_EQ(FirstGLError(), 0u); + EXPECT_GT(px[1], 200) << "the draws inside the counted window never landed, so the count below " + "would be a number about nothing"; + + Gl().EndFrame(); // the swap that emits the window covering exactly the work above + const PipeStatsWindow::Window window = PipeStatsWindow::LastFromLaneLog(); + ASSERT_TRUE(window.found) << "no 'MGPipe stats:' line in " << PipeStatsWindow::LibraryLogPath() + << ": either MOBILEGL_PIPE_STATS / MOBILEGL_PIPE_STATS_PERIOD did not " + "reach the process, or nothing reached PipeStats::OnPresent."; + RecordProperty("stats_line", window.line.c_str()); + + const long long roundtrips = PipeStatsWindow::CounterOrAbsent(window, "mpr"); + ASSERT_GE(roundtrips, 0) << "the summary line carries no mpr= field: " << window.line; + EXPECT_EQ(roundtrips, 1) + << "one storage definition of an adopted arena is one map_persistent emission " + "(ARCHITECTURE.md:474, D-B2: mint OR decline, both need an answer from the resource " + "owner). This window defined the arena once and drew from it " + << kDrawsInTheWindow << " times, so 1 is the whole cost; " << kDrawsInTheWindow + << " would mean the acquisition moved onto the draw path - the ~167 ms/arena hiccup this " + "adoption removed, re-introduced - and 0 would mean the emission stopped happening. It " + "reported: " + << window.line; + } + } // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/MonolithAttachmentClearScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/MonolithAttachmentClearScenario.cpp new file mode 100644 index 000000000..d141d74eb --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/MonolithAttachmentClearScenario.cpp @@ -0,0 +1,180 @@ +// MobileGL - MobileGL/MG_IntegrationTest/Scenarios/MonolithAttachmentClearScenario.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header +// +// Scenario - CLEARING A FRAMEBUFFER THAT HOLDS AN ATTACHMENT, ON THE PUSH-MONOLITH ARM. +// +// THE HOLE THIS CLOSES (INTEGRATOR-DECISIONS-P5E ID-107). P5e landed with 2250 unit entries and +// 113 `integration-split` entries green, and the game then died at startup under +// MOBILEGL_TRANSPORT=monolith inside Lightmap. -> clearColorTexture -> glClear: +// SyncCurrentFBO -> BackendFramebufferObject::SyncToBackend(frontend FBO) -> +// SyncAttachmentSurface -> SyncMipmapsToBackend(null SharedPtr) -> SIGSEGV. Neither gated lane +// could see it, for two reasons that are one reason: +// +// * the gate runs `-L unit` and `-L integration-split`, and EVERY split entry sets +// MOBILEGL_TRANSPORT=inproc, which selects the by-handle framebuffer arm +// (FramebufferRecordArmIsMandatory) - the arm that never reaches the frontend overload at +// all; and +// * every P5-era scenario that DOES clear a texture-attached framebuffer is split-only by +// construction (F1WireScenario and friends call SplitRuntimeSkipReason() in SetUp and skip +// the moment the runtime is not split), so the ambient monolith registration of those files +// runs zero cases. +// +// So the arm the phone ships on - MOBILEGL_PIPE_PUSH compiled, transport monolith, framebuffer +// records produced and applied IN PROCESS (P4a D-C2), attachments resolved from those records - +// had no gated entry that attached anything to a framebuffer and cleared it. This file is that +// entry, and its registration block (MG_IntegrationTest/CMakeLists.txt, the +// `DirectGLES.PushMonolithArm.` prefix) pins the transport with a ctest ENVIRONMENT property for +// review finding N-6's reason: a job-level MOBILEGL_TRANSPORT=inproc must not be able to turn +// this lane into a second copy of the split one. +// +// WHAT EACH CASE IS FOR. The three attachment KINDS are the three arms of +// FramebufferImpl::SyncAttachmentSurface, and on the monolith arm each must drive its storage +// from the frontend object the walk is holding rather than from a by-handle seam whose record +// arm is selected by `Transport != Monolith` (CONTRACT-P5E §5.8 / ID-81): +// +// * a MUTABLE 2D texture (glTexImage2D, the Lightmap shape) - the texture arm, whose storage +// sync is the one that crashed; +// * an IMMUTABLE 2D texture (glTexStorage2D) - the same arm with the levels already allocated, +// so a regression that only shows up while levels are still being defined cannot hide the +// other half; +// * a RENDERBUFFER - the sibling arm, whose by-handle form does not dereference anything but +// does drop the application-visible GL_OUT_OF_MEMORY report, so it is arm-selected too. +// +// The assertion is the PIXEL, not "it did not crash": a clear that reaches a driver framebuffer +// with no attachment raises GL_INVALID_FRAMEBUFFER_OPERATION and writes nothing, which is the +// silent half of the same defect and is what an attach that was refused rather than crashed +// would produce. Both are failures here. +// +// Backend-agnostic on purpose. Nothing below is a DirectGLES fallback: "a clear of a complete +// framebuffer reaches its own attachment" is a property of GL, so both ambient registrations +// assert it and the scenario fails wherever it stops being true. + +#include +#include + +#include "../Harness/HeadlessGL.h" +#include "../Harness/ScenarioFixture.h" + +#ifdef GLAPI +#undef GLAPI +#endif +#define GL_GLEXT_PROTOTYPES +#include +#include +#undef GL_GLEXT_PROTOTYPES + +namespace MGITest { + namespace { + + constexpr int kSize = 8; + + // 0.25 / 0.5 / 0.75 / 1.0 - the F1 lane's own clear values, so a reader comparing the two + // arms is comparing the same numbers. None of them is 0 or 1, so neither a cleared-to-zero + // texture nor an untouched one can pass by accident. + constexpr float kClearR = 0.25f; + constexpr float kClearG = 0.5f; + constexpr float kClearB = 0.75f; + constexpr GLubyte kExpected[4] = {64, 128, 191, 255}; + + class MonolithAttachmentClearScenario : public ScenarioTest { + protected: + GLuint fbo = 0; + GLuint texture = 0; + GLuint renderbuffer = 0; + + void SetUp() override { + ScenarioTest::SetUp(); + if (!Ready()) return; + glGenFramebuffers(1, &fbo); + glBindFramebuffer(GL_FRAMEBUFFER, fbo); + glDisable(GL_SCISSOR_TEST); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glViewport(0, 0, kSize, kSize); + } + + void TearDown() override { + if (!Ready()) return; + glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (texture) glDeleteTextures(1, &texture); + if (renderbuffer) glDeleteRenderbuffers(1, &renderbuffer); + if (fbo) glDeleteFramebuffers(1, &fbo); + ScenarioTest::TearDown(); + } + + // Clear the bound framebuffer and read back the pixel the F1 lane reads. Separated + // from the attach so that a failure says which half broke. + void ClearAndExpectThePixel(const char* what) { + ASSERT_EQ(glCheckFramebufferStatus(GL_FRAMEBUFFER), GLenum(GL_FRAMEBUFFER_COMPLETE)) + << what << ": the framebuffer never came up, so the clear below would prove nothing"; + glClearColor(kClearR, kClearG, kClearB, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + GLubyte pixel[4] = {0, 0, 0, 0}; + glReadPixels(2, 3, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + // A clear issued against a driver framebuffer whose attachment point was never + // filled in raises this and writes nothing - the silent form of the defect. + EXPECT_EQ(FirstGLError(), 0u) << what << ": " << GLErrorName(FirstGLError()); + for (int i = 0; i < 4; ++i) { + EXPECT_NEAR(pixel[i], kExpected[i], 1) + << what << ": channel " << i << " of the attachment did not receive the clear"; + } + } + }; + + // THE DEVICE'S OWN SHAPE: a mutable texture whose level 0 is defined by glTexImage2D and + // then attached, which is what Lightmap. does before its clearColorTexture. The + // storage sync behind the attachment is the call that took the null frontend object. + TEST_F(MonolithAttachmentClearScenario, AClearOfAMutableTextureAttachmentReachesItsPixels) { + if (!Ready() || IsSkipped()) return; + + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + std::vector zeros(static_cast(kSize) * kSize * 4, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kSize, kSize, 0, GL_RGBA, GL_UNSIGNED_BYTE, zeros.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_MAX_LEVEL, 0); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + + ClearAndExpectThePixel("mutable 2D texture attachment"); + } + + // The same arm with immutable storage: the levels are allocated before the attach, so a + // sync that only misbehaves while a level is still being defined cannot account for a + // green here. + TEST_F(MonolithAttachmentClearScenario, AClearOfAnImmutableTextureAttachmentReachesItsPixels) { + if (!Ready() || IsSkipped()) return; + + glGenTextures(1, &texture); + glBindTexture(GL_TEXTURE_2D, texture); + glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA8, kSize, kSize); + ASSERT_EQ(FirstGLError(), 0u) << "the driver refused the immutable storage itself"; + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0); + + ClearAndExpectThePixel("immutable 2D texture attachment"); + } + + // The renderbuffer arm of the same function. It cannot crash the way the texture arm did + // - the by-handle allocation dereferences no frontend object - but it IS the other half of + // the arm selection, and a regression that routes it back through the by-handle form on + // the monolith arm silently drops the application's GL_OUT_OF_MEMORY report. + TEST_F(MonolithAttachmentClearScenario, AClearOfARenderbufferAttachmentReachesItsPixels) { + if (!Ready() || IsSkipped()) return; + + glGenRenderbuffers(1, &renderbuffer); + glBindRenderbuffer(GL_RENDERBUFFER, renderbuffer); + glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, kSize, kSize); + ASSERT_EQ(FirstGLError(), 0u) << "the driver refused the renderbuffer storage itself"; + glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, renderbuffer); + + ClearAndExpectThePixel("renderbuffer attachment"); + } + + } // namespace +} // namespace MGITest diff --git a/MobileGL/MG_IntegrationTest/Scenarios/ObjectSubsystemControlScenario.cpp b/MobileGL/MG_IntegrationTest/Scenarios/ObjectSubsystemControlScenario.cpp new file mode 100644 index 000000000..4e03fc5ed --- /dev/null +++ b/MobileGL/MG_IntegrationTest/Scenarios/ObjectSubsystemControlScenario.cpp @@ -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 +#include +#include +#include +#include +#include + +#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 +#include +#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 "[