Compare commits

...
33 Commits
Author SHA1 Message Date
swung0x48 6672778b80 [Merge] (dev): merge dev@50fb1343 into feat/disaggregated
- brings the write-map landing into GPU-resident stores, the open-ended fp64 storage block flattening, the idle-based CTS chunk timeout and the MSVC /WHOLEARCHIVE test link
- verified on the merged tree: unit 1458, integration 868, Wire 54, retrace 79/79
2026-09-05 22:13:21 -04:00
swung0x48 6e0e3df372 [Docs] (Disaggregated): point the history note at commit hashes only
- the retired drafts stay in git history; the README no longer names or characterises them
2026-09-05 21:58:28 -04:00
swung0x48 bee22f9d26 [Docs] (Disaggregated): separate the dirty-surface totals from the immediate-publish subset in MEASUREMENTS
- the sentence read as if 836 of the 92 immediate calls were RecordError; 836 is RecordError's share of all 926 calls, and most of the 92 immediate ones are RecordError
2026-09-05 21:57:11 -04:00
swung0x48 8952b14024 [Docs] (Disaggregated): rewrite the MGPipe plan into a design and architecture set - README, ARCHITECTURE, ROADMAP, MEASUREMENTS - and retire PLAN.md and REVIEW.md to git history
- README.md: what MGPipe is, the one-paragraph architecture, the P0 status line, the file and code map, and the commit range where the design competition and the three adversarial review rounds live
- ARCHITECTURE.md: the design as decided, one reason per decision - handles and generations, the 71-call catalogue by class with flags and cap bits, record and payload conventions, the tracker, texture subdata and dirty ownership, shader state and the P0.5 header extraction, the reverse channel, the backend strangler, the server side and the index host mirror, the transport as landed in MG_Remote, the persistent-map tiers as measured, roundtrips, present and threads, process and platform delivery, build shapes and purity gates, the five-part verification gate, and the knob tables marked landed versus planned
- ROADMAP.md: P0..P13 as one table of what lands, the gate and the dependency, the two tracks and milestones, the day-43 GO/NO-GO checklist with both exits, the re-baseline checkpoints, and the questions still open after P0
- MEASUREMENTS.md: spike A on both devices, the spike B tier matrix, the four-trace boundary-counter baselines on both backends, the desktop and corpus facts, and the harness traps with the exact commands
- every file:line kept is verified at 458ccde1 and the citation lint is clean; everything else cites a symbol plus a file
- dropped on purpose: the v1/v2 revision archaeology, rejected alternatives, per-subsystem day estimates, the Feat/CS-Delta-IPC reuse audit and the reviewer back-and-forth
2026-09-05 21:56:31 -04:00
swung0x48 458ccde176 [Feat] (Metrics, Config): make the boundary-counter summary cadence a knob, because the device harness never reaches the teardown dump
- MOBILEGL_PIPE_STATS_PERIOD (default 120, clamped to [1, 1000000]) sets the frames per
  summary line; Init() latches it and a zero falls back to the default
- the trace APK's replay never tears MobileGL down, so MOBILEGL_PIPE_STATS_FILE never
  fires on device and a fixture shorter than the period (create-indirect) reported nothing
- PipeStatsTest pins the latch and the zero fallback
2026-09-05 21:21:54 -04:00
swung0x48 901d48a678 [Fix] (MGPipe, Metrics, Config, CI): exchange the per-frame stats instead of racing a store, carry the three uncarried table entries, drop the inline host span from a buffer range, spell the buffer subdata range, and close the small gate holes
- S-1 PipeStats::OnPresent read each frame accumulator and then store(0)'d it; a Bump from a staging thread landing in between was lost from the Tracy plot and from every frame. Each accumulator is now exchange(0, relaxed) and the exchanged value is what is plotted, so every add lands in exactly one frame.
- T-3 FdPassing without MSG_CMSG_CLOEXEC (macOS, BSD) handed back descriptors that survived exec; every received fd now gets FD_CLOEXEC by hand under !MSG_CMSG_CLOEXEC. MSG_NOSIGNAL is defined to 0 where the platform lacks it (FdPassing.cpp, Doorbell.cpp) and SO_NOSIGPIPE is set on the socketpair and on a SocketDoorbell's descriptor where it exists, so a write to a hung-up peer is EPIPE rather than a fatal signal.
- T-4 the missing-flatbuffers fallback wrote OFF into the cache with FORCE, so a plain re-configure after `git submodule update` stayed OFF silently. It is a normal-variable set now, shadowing the cache for that configure only; verified by hiding flatbuffers.h, configuring with ON (warning, transport off, cache still ON) and re-configuring plainly with the header back (transport ON).
- P-2 three LIVE GLFunctionsTable entries had no carrier: GetGpuTimestampNs (glGetInteger64v(GL_TIMESTAMP), a synchronous server answer), QueryCounterTimestamp (glQueryCounter, a one-shot stamp, not a begin/end pair) and WaitSync (the GPU-side wait FenceWait's client wait does not express). QueryTimestamp (MGPTimestampRequest, kCtxQuery, kReplySlot), QueryCounter (MGPQueryDesc with Kind = GL_TIMESTAMP, kCtxQuery) and FenceWaitServer (MGPFenceWait, kScreen) are APPENDED at the end of PipeCalls.def because the opcode is the position: SetSwapInterval stays 68, the three take 69-71, and PipeCatalogue.LateArrivalsAreAppendedWithoutRenumbering pins that. Header counts 71 (screen 11, query 8); the seven generators regenerated.
- P-3 MGPBufferRange inlined a 32-byte MGHostSpan into every range of every class - dead space on every SSBO, atomic-counter and XFB range, and D-B8 says not to freeze the named-UBO payload before the stage-ubo-named numbers exist. The range is 24 bytes now; the host spans are an optional second var-tail behind the ranges, announced by MGPShaderBuffers::HostSpanCount (0 or Count), with set_shader_buffers keeping its kVarTail|kHostSpan flags. PipeCatalogue.BufferRangeCarriesNoInlineHostSpan pins the sizes, the flags and the comparator's view of the count.
- P-4 QueryEnvUint64 parsed with base 0 (a leading zero meant octal: MOBILEGL_PIPE_PUSH=010 read as 8) and accepted -1 as every bit set; it is decimal or explicit 0x now and a '-' anywhere is refused with the warning (smoke through the integration binary: -1 and 12abc warn, 010 and 0x10 parse). The CI stdio gate's alternation now also catches fprintf(stdout, puts( and std::cout/cerr; it is green over MG_Backend and MG_State. MGPSubData states how the buffer half expresses [offset, size): UnionBox.X / UnionBox.W with Target == Buffer, Y = Z = 0, H = D = 1, one record bounded at a 2^31-1 offset and 2^32-1 size beyond which the emitter splits (the same rule the ring's half-capacity bound already imposes); MGPipeSetSubDataBufferRange / MGPipeSubDataBufferOffset / Size are the only spelling and PipeCatalogue.SubDataBufferRangeRidesInTheUnionBox pins the encoding and its bounds. gen_pipe.py now refuses, in both modes, a call payload named in PipeCalls.def with no field list in PipeFields.def (the four memcmp-fallback member types are the documented exception); shown by dropping P(MGPSwapInterval), which exits 1 naming the payload.
- The MGPPixelPackState size assertion compared sizeof against itself; it asserts the literal 28 PixelStoreParameters measures.
- Verified: ctest -L unit green in both the default and the split configuration, gen_pipe.py --check clean with the generated files committed, nm --defined-only of the default libMobileGL.so has no MG_Remote symbol, and the full integration-gpu suite passes (the *IsActuallyArmedWhenTheEnvironmentPinsItOn family trips under -j 8 as documented and passes serially).
2026-09-05 21:19:00 -04:00
swung0x48 e8ee7b1a88 [Feat] (Backend, MGPipe): carry the six per-axis compute limits in DynamicBackendParameters so MGPCaps has every backend-owned indexed answer, and pin them against glGetIntegeri_v on both backends
- P-1: MGPCaps is DynamicBackendParameters by inclusion (plan B section 4.4.1), but that struct carried MaxComputeWorkGroupInvocations and no per-axis GL_MAX_COMPUTE_WORK_GROUP_COUNT / GL_MAX_COMPUTE_WORK_GROUP_SIZE - the six numbers that ARE the backend-owned indexed answers surviving the getter retirement (GL_Getter.cpp and CompileEnv.cpp ask GLFunctionsTable::GetIntegeri_v for exactly these, DirectVulkan answers them from VkPhysicalDeviceLimits), so the interface had a hole where its only genuine indexed carrier should be. DynamicBackendParameters now has MaxComputeWorkGroupCount[3] / MaxComputeWorkGroupSize[3] with the GL 4.3 minimums as the no-backend defaults; DirectGLES fills them from glGetIntegeri_v inside the loader's bracketed probe run (GLESCapabilities carries them, logged with the other limits) and DirectVulkan from maxComputeWorkGroupCount / maxComputeWorkGroupSize through the loader's SaturateToInt like every other limit. Raw driver answers, as the invocations limit is: the frontend floors them at the shared MIN_COMPUTE_WORK_GROUP_* minimums itself.
- The GetIntegeri_v table path is untouched, as is GL_Getter and CompileEnv behaviour: retiring the getter in favour of the caps is P0.5, and this only makes sure the caps have what P0.5 needs.
- PipeCalls.def's footer no longer claims that "only GL_COMPUTE_WORK_GROUP_SIZE is a real backend answer and it lives in MGPCaps": the six limits live in MGPCaps, and GL_COMPUTE_WORK_GROUP_SIZE is a frontend link artifact (ProgramObject::GetComputeLocalSize, what GL_Program.cpp answers from), which AdvertisedLimitsScenario.ComputeLocalSizeComesFromTheLinkedProgram already pins. The MGPCaps size assertion is a composition of sizeof(DynamicBackendParameters) and follows the struct.
- AdvertisedLimitsScenario.ComputeWorkGroupLimitsAreTheCapsBlocksAnswer pins, on both lanes: answerability, the GL 4.3 floors, vector/indexed agreement, INVALID_VALUE past axis 2, and - through the new Harness/BackendCapsPeek translation unit, which is the one place the module looks past the GL API - that max(caps, minimum) equals the live glGetIntegeri_v answer axis by axis. Shown live by halving each backend's caps copy: both lanes fail with "MGPCaps carries 512 but glGetIntegeri_v answers 1024". On Android the module links the shipping .so (hidden visibility), so the peek returns false there and only the GL-visible half runs. ComputeWorkGroupCapabilities.TakesEveryAxisFromTheIndexedQuery in BackendLoaderTest pins the DirectGLES loader half against the fake driver, per axis and above the initialisers.
- Verified: AdvertisedLimitsScenario 20/20 on DirectGLES and DirectVulkan (llvmpipe / lavapipe), BackendLoaderTest green.
2026-09-05 21:19:00 -04:00
swung0x48 1154f9a00d [Fix] (MG_Remote, Transport): give the inproc doorbell a death state so Shutdown can join a parked waiter, and bound a ring record at half the capacity so a refusal can never look like backpressure
- T-1: InProcessChannel::Close rang each CondVarDoorbell once and claimed that unparks a peer mid-frame. It does not. Doorbell::Wait consumes the one ring, re-tests a condition nothing published, finds the bell alive (CondVarDoorbell never overrode Dead(); Doorbell.cpp had no death state at all) and with kWaitForever parks again for good - so Shutdown could never join a server thread sitting in the design's own steady state (spun, set consumerParked, blocked; plan section 8.1 inheriting the earlier plan's 6.2a). CondVarDoorbell now carries an atomic death latch: Kill() sets it under the mutex and notify_all's, Dead() reports it, Park returns false at once on a dead bell (and the wait predicate includes it, so a Kill cannot slip between the test and the wait), and Close kills both bells instead of ringing them. Same shape as SocketDoorbell's EOF latch; Notify stays the ordinary wakeup.
- T-2: RingProducer::Reserve refused only total > capacity, but a record with capacity/2 < total <= capacity is unplaceable at every head offset where neither the space to the wrap boundary nor the space before it holds it - even in an EMPTY ring, because a wrap pad costs spaceToEnd bytes on top of the record. Concretely: head offset 16 of an empty 256-byte ring, a 248-byte record; FreeBytes() says 256, Reserve says nullptr, forever, and a producer waiting for FreeBytes() >= 248 stalls with nothing logged. The bound is now capacity/2, which is exact rather than conservative (worst case 2*total-8 <= capacity-8), exposed as MaxRecordBytes() for the emitter to chunk against; the minimum ring is two headers so the smallest record still fits the bound. Ring.h states Capacity()/2 as the chunking bound and the G3 header comment in gen_pipe.py now states the chunking rule plan section 8.2 asks G3 to define (PipeWire.inc regenerated).
- Tests, each shown red with only the fix site reverted and green with it: InProcessTransportTest.ShutdownUnparksAWaiterWithNoDeadline (bounded join through a shared_ptr-owned waiter: 5 s red instead of a hung job; reverted it hangs and fails at 5051 ms), RingTest.RecordLargerThanHalfTheRingIsRefused (reverted, the 248-byte record is accepted), RingTest.RecordPlaceabilityDoesNotDependOnTheHeadOffset (the offset-0 vs offset-16 negative control), RingTest.HalfCapacityRecordFitsAtEveryHeadOffset (the positive half: the maximal record at all 32 head offsets of a 256-byte ring) and RingTest.RejectsARingTooSmallForTheSmallestRecord.
2026-09-05 21:19:00 -04:00
swung0x48 7ef7c7e543 [Fix] (Spikes): answer the tier question for DirectGLES too, make an OK mean bytes round-tripped through a real GPU access, and exercise T3 in the direction that makes it a tier
- plan-B §8.3 asks which tier `AcquirePersistentMap` lands in, but the probe only
  asked Vulkan. DirectGLES ("Espryt") reaches a persistent map through
  `glBufferStorageEXT` + `glMapBufferRange(PERSISTENT|COHERENT)`, not a
  `VkDeviceMemory` map, so a Vulkan-only answer decides nothing for that backend.
  Add a GLES leg to T1: the exported fd imported with `glCreateMemoryObjectsEXT`
  + `glImportMemoryFdEXT` + `glBufferStorageMemEXT`, then mapped
  PERSISTENT|COHERENT -- in-process first (isolates "GL can import this fd" from
  "the fd survives a process boundary"), then cross-process (new `t1gl` child).
  Drivers disagree about how the import must be phrased, so each attempt walks a
  ladder over {dedicated flag} x {import size = memory requirement or the fd's
  own size} x {buffer size} and reports the rung the driver accepted plus every
  rejected rung with its GL error -- a driver *preference* must never be reported
  as a missing capability. A driver that backs the storage but refuses
  PERSISTENT|COHERENT is reported separately from one that refuses the storage:
  that distinction is exactly T1 vs T2 for DirectGLES. The T0 GLES leg
  (`EGL_ANDROID_get_native_client_buffer` + `glBufferStorageExternalEXT` +
  persistent map, verified by `AHardwareBuffer_lock` on the client side) now
  reports every step's GL enum and requires the persistent flags for OK.
- the verdict was unfalsifiable: T1 reported PARTIAL when neither leg had moved a
  byte. Replace it with an explicit decisive-leg model -- OK only when every
  decisive leg round-tripped in both directions, PARTIAL when at least one did,
  FAIL otherwise with the failing step and its driver error named in `why:`.
  Every row now opens with a per-leg trace (`vkimport[D]=rt gpu[D]=rt`). The raw
  `mmap` leg is informational for opaque-fd (Vulkan forbids interpreting that
  payload outside the driver, so a refusal is conformant) and decisive for
  dma-buf, where a CPU mapping is the point of the handle type.
- T3 never ran the direction that would make it a tier: both ends were the
  importing process. Add `T3-client-memfd-server-import` (new `t3c` child) -- the
  client creates and writes the memfd, the server mmaps the received fd, imports
  the client's host pointer into a `VkDeviceMemory`, reads what the client wrote,
  writes back, and takes a GPU access on the client's memory, which the client
  then verifies through its own mapping.
- no route touched the GPU, so an OK proved only that a map call returned a
  pointer. Every tier row now takes a real GPU access before it can be OK:
  `vkCmdCopyBuffer` out of the shared allocation into private staging (mismatch =
  the GPU could not read what the peer wrote) plus `vkCmdFillBuffer` into it,
  queue-idle and an explicit host-read barrier, with the peer checking the filled
  region through its own mapping. VkCtx grows a queue and command pool for it.
- the device run executes in the `shell` SELinux domain, not the `untrusted_app`
  domain MobileGL runs in, and the two do not share dmabuf/gralloc rules. Print
  uid/pid/`/proc/self/attr/current` in a run-context header, repeat the caveat in
  the summary, and document in README.md how to answer it for the real domain
  later (exec the same binary from the trace app's spike hook, spike-A package)
  without implementing that here.
- `vkStr()` returned a pointer into one static buffer while several results
  routinely appear in one format call, so all of them showed the last one; it
  returns std::string now, `memFlagStr` likewise, and `fmt`/`pr` carry
  `format(printf)` so a missed `.c_str()` is a compile error rather than UB.
- `advertisedExportable` decided the status at the allocate site but not at the
  `vkGetMemoryFdKHR` site. One rule at every export failure now
  (`exportFailStatus`): advertised EXPORTABLE and then declining is FAIL, never
  advertised is UNSUPPORTED. Export + map + fd is factored into `exportHostVisible`.
- `T0-ahb-blob-transfer` was recorded OK on the socket handoff alone. The handoff
  keeps its own informational row; the tier row is now composed at the end from
  the full import+map+compare+writeback chain over the Vulkan, GL and GPU legs.
- `mmapErrno` kept the first attempt's errno after the second-chance mmap
  succeeded, so a working mapping carried a failure code; it is cleared on
  success and the first errno moves into the note.
- a failed `glImportMemoryFdEXT` no longer closes the fd: EXT_memory_object_fd
  does not say whether ownership still transfers on failure and Mesa closes it
  either way, so closing risks a double close landing on the socket. Leaking a
  handful of dups in a short-lived probe is the safe side of that trade.
- validated end to end on the host harness (lavapipe + llvmpipe,
  `VK_DRIVER_FILES=lvp_icd.json EGL_PLATFORM=surfaceless`): T1-opaque-fd OK,
  T3-external-memory-host OK, T3-memfd-cross-process OK,
  T3-client-memfd-server-import OK, T1-dma-buf UNSUPPORTED (not advertised
  exportable). The two T1-gles rows FAIL there with GL_OUT_OF_MEMORY on every
  ladder rung although GL_DEVICE_UUID_EXT matches the Vulkan deviceUUID --
  llvmpipe's GL does not implement importing a lavapipe opaque-fd allocation,
  a Mesa interop gap recorded in README.md so a device FAIL stays attributable.
  Rebuilt for arm64-v8a with NDK r27d (PIE, android-30); the device run is
  pending, both device locks are held by another campaign.
2026-09-05 20:50:05 -04:00
swung0x48 6c7ad0a1bf [Feat] (Spikes): add the standalone external-memory probe that decides the persistent-map tier
- Plan B §11 P0 requires spike B ("external memory 导出,两台设备") to run before the
  persistent-map decision of §8.3 can be taken: T0 (server imports a client
  allocation), T1 (server exports its own HOST_VISIBLE|HOST_COHERENT allocation)
  or T2 (AcquirePersistentMap returns nullptr, making the §5.10 client-side block
  push mandatory). §8.3 says the answer must be measured on the two campaign
  devices, and that a platform unknown must not block the interface work.
- tools/spikes/extmem_probe/ is a self-contained NDK command-line program: it
  links vulkan/EGL/GLESv3/android/log and nothing from MobileGL, is configured by
  its own CMakeLists with the android toolchain file, and is deliberately absent
  from the project's build graph (the root CMakeLists only pulls in
  tools/trace_replay), so the default ALL target is untouched.
- Phase A enumerates VK_KHR_external_memory_fd, VK_EXT_external_memory_dma_buf,
  VK_EXT_external_memory_host, VK_ANDROID_external_memory_android_hardware_buffer
  and, through a headless EGL pbuffer context, GL_EXT_memory_object{,_fd},
  GL_EXT_external_buffer, GL_EXT_buffer_storage, GL_OES_EGL_image_external{,_essl3}
  and EGL_ANDROID_get_native_client_buffer, plus the memory-type table and the
  vkGetPhysicalDeviceExternalBufferProperties verdict per handle type for the exact
  buffer usage set MobileGL needs.
- Route T1 allocates a HOST_VISIBLE|HOST_COHERENT buffer memory with
  VkExportMemoryAllocateInfo, writes a pattern through vkMapMemory, exports an fd
  with vkGetMemoryFdKHR (opaque-fd and, where advertised, dma-buf), hands it to a
  second process over SCM_RIGHTS, and has that process both mmap() the fd and
  import it into its own VkDeviceMemory + vkMapMemory. Both sides write and both
  sides compare, so a copy-on-import or one-directional mapping is reported as
  PARTIAL rather than as success.
- Route T0 has the second process allocate an AHardwareBuffer BLOB
  (CPU_READ_OFTEN|CPU_WRITE_OFTEN|GPU_DATA_BUFFER), send it with
  AHardwareBuffer_sendHandleToUnixSocket, and the first process import it three
  ways -- AHardwareBuffer_lock, VkDeviceMemory via
  VK_ANDROID_external_memory_android_hardware_buffer, and a GL buffer via
  eglGetNativeClientBufferANDROID + glBufferStorageExternalEXT mapped
  persistent/coherent -- with a write-back leg the allocating process verifies.
- Route T3 covers VK_EXT_external_memory_host: a memfd-backed mmap region aligned
  to minImportedHostPointerAlignment, imported through
  VkImportMemoryHostPointerInfoEXT, plus the same memfd handed to another process.
- The second process is /proc/self/exe re-exec'd with --child=<route> and one end
  of a socketpair on fd 3. A bare fork() is not usable: neither side's Vulkan
  driver survives fork, and both sides need live Vulkan. It is also the topology
  the transport actually has (§8.1, inheriting PLAN.md §11.1-§11.6: the client
  spawns the server), so the probe measures the arrangement that would ship.
- The probe also builds for the host with T0 compiled out. That is not scope
  creep: a negative device result is only worth something if the harness is known
  to report a working route as working. Running it on lavapipe did that, and paid
  for itself immediately by exposing two harness bugs that would have produced
  false negatives on the devices -- (a) the child wrote through its plain mmap
  before reading through the Vulkan import, so on a driver whose exported fd maps
  at an offset the probe overwrote the very payload the second read compares
  (lavapipe reports payloadAt=4096); reads through both mappings now precede
  writes through either, and the offset is searched for and reported; (b) an
  export failure on a handle type the driver never advertised as EXPORTABLE was
  classified FAIL instead of UNSUPPORTED (lavapipe's dma-buf answer).
- Output is a RESULT/summary table carrying the raw driver verdicts (VkResult
  names, errno, GL enums) because those codes -- not a pass/fail bit -- are what
  §8.3 needs in order to pick the tier.
- Built with NDK 27.3.13750724 for arm64-v8a / android-30, RelWithDebInfo, PIE,
  warning-clean; host build clang/RelWithDebInfo, warning-clean.
2026-09-05 20:50:04 -04:00
swung0x48 38d4c2372c [Feat] (TraceApp, CI): pass arbitrary env vars through the retrace lane
- PLAN-B.md §8.2 and appendix B add a batch of new runtime switches
  (MOBILEGL_PIPE_PUSH / _VERIFY / _STATS / _LEGACY_MEMOS / _TEXEL_RETAIN_MB /
  _INDEX_MIRROR_MB, plus MOBILEGL_IPC_* later), and §11 P0 wants them parsed beside
  the existing ones. Today every knob that has to reach an Android replay costs an
  edit in five files - run_android_retrace_local.py, trace-replay-ci.sh,
  TraceReplayActivity's request record, the JNI marshalling, and the setenv block in
  trace_replay_core.cpp. That per-knob tax is what this replaces: one extra,
  `--es mobilegl_env "K=V;K=V"`, carries all of them.
- Applied last, immediately before dlopen(libMobileGL.so), so it can also override
  the dedicated fields above it - MobileGL's config is read during the load, and an
  escape hatch that cannot beat the defaults is not one. An entry with no '=' unsets
  the variable, which is the only way to clear a default the marshalling sets.
- The existing per-knob flags stay: they carry semantics beyond a setenv (use_angle
  also selects a variant, the dump lists are joined, DirectVulkan forces the
  R11G11B10F fallback), and rewriting them as env strings would move that logic into
  the callers.
- Surface: --env / MOBILEGL_TRACE_ENV in trace-replay-ci.sh, repeatable --env
  KEY=VALUE in run_android_retrace_local.py, `mobilegl_env` intent extra,
  Request::envOverrides.
- The two-level parse now lives in trace_env_overrides.hpp, beside the semicolon
  splitter it shares with the texture and FBO dump lists, and
  tools/trace_replay/trace_env_overrides_test.cpp pins it: the empty entries a
  trailing ';' leaves behind must not become unsetenv(""), `K=` must stay a Set of
  the empty string rather than an Unset (a knob read with getenv() != nullptr sees
  those as opposite answers), and only the FIRST '=' may separate, or a value
  carrying '=' is truncated without a word of warning. The whole MOBILEGL_PIPE_*
  batch rides on this parse, and the only lane that exercised it end to end was an
  on-device retrace, which would have reported a splitting bug as "the knob had no
  effect".
- The check is built and RUN at build time and mobilegl_trace_replay depends on it,
  so `cmake --build ... --target mobilegl_trace_replay` - the exact command of
  test.yml's "Build trace replay" job, which never invokes ctest - runs it. It is
  assert-free on purpose: that lane configures Release, and <cassert> under NDEBUG
  would compile every check into a green run that checked nothing. Negative control:
  swapping find('=') for rfind('=') fails 2 checks, and keeping the splitter's empty
  entries fails 2 more.
2026-09-05 20:49:57 -04:00
swung0x48 8a239177ac [Feat] (Build, TraceApp): ship and exec a second native binary on android
- PLAN-B.md §11 P0 lists spike A (the Android delivery chain) as a P0 deliverable,
  inherited verbatim from PLAN.md §15 P0; §8.1 inherits PLAN.md §11.1-§11.6, whose
  Android path needs a second process. Android gives an application no writable
  exec-able directory, so the only supported route is to name the binary lib*.so, let
  the packager put it in lib/<abi>/, and exec it out of
  getApplicationInfo().nativeLibraryDir. This builds that route end to end so the
  spike can be answered with evidence instead of folklore.
- New root option MOBILEGL_BUILD_SERVER_SPIKE (OFF, ANDROID-only) adds the
  MobileGLServer target from tools/spikes/server_stub/main.cpp with PREFIX "lib" /
  SUFFIX ".so" and -fPIE/-pie: an .so name does not exempt the file from Android's
  PIE requirement. Its RUNTIME_OUTPUT_DIRECTORY is pointed at
  CMAKE_LIBRARY_OUTPUT_DIRECTORY, because AGP packages what lands in the per-ABI
  library output directory and CMake would otherwise put an executable elsewhere.
- The option is opt-in on both sides. The plugin flavour cannot turn it on at all,
  and the trace flavour builds it only when asked, with
  `-Pmobilegl.buildServerSpike=ON` or MOBILEGL_BUILD_SERVER_SPIKE=ON in the
  environment; a flavour that silently carries an executable nothing loads is the
  kind of thing nobody notices until it ships. Verified both ways:
  assembleTraceDebug -Pmobilegl.buildServerSpike=ON packages
  lib/arm64-v8a/libMobileGLServer.so and `file` reports "ELF 64-bit LSB pie
  executable, ARM aarch64 ... interpreter /system/bin/linker64, for Android 26";
  the same task with no property packages only libMobileGL.so and
  libtrace_replay_runner.so.
- The stub prints one line to stdout and writes the same line to the file named by
  argv[1], then exits 0. The line carries pid/ppid/uid/gid and, decisively, the
  child's own /proc/self/attr/current: only `u:r:untrusted_app:...` proves an
  ordinary app process did the exec. An `adb run-as` shell runs in a different
  SELinux domain, so a success there would prove nothing.
- RunSpawnSpike() starts the stub with argv [serverPath, markerPath], redirects the
  child's stdout/stderr into a captured file (an app process has stdout on
  /dev/null, so a printed line would otherwise vanish), waits for it, and reports
  exit status, signal, the exec errno, the parent's own SELinux context, the marker
  content and the captured stdout - to logcat, to the returned string, and to a
  <marker>.report file, because the Activity finishes immediately afterwards.
- The child reports the errno of a REFUSED execve through a close-on-exec pipe.
  Without it the one datum the spike exists to produce is lost: the parent only ever
  sees a wait status, in which every reason has already been flattened into one exit
  code, and EACCES (SELinux, or a noexec mount) versus ENOEXEC (a packager that
  mangled the file) are opposite verdicts for the design. A successful exec closes
  the write end for free, so the parent reads EOF and reports execErrno=0.
- fork/execve only. The earlier draft also carried a posix_spawn arm behind
  `__ANDROID_API__ >= 28`, which was dead code in every configuration this repo can
  build - bionic declares posix_spawn from API 28 and the root CMakeLists.txt pins
  MOBILEGL_ANDROID_API_LEVEL to 26 and refuses to configure lower - and would have
  silently become the production path, untested, on a minSdk bump. Keeping the arm
  that actually ships means the spike measures the code the server would really use.
  Nothing happens between fork and execve except open/dup2/execve/write/_exit, all
  async-signal-safe, because the parent is a multi-threaded JVM process.
- The spike lives in its own TU, spawn_spike.cpp/.hpp, listed only by the trace
  APK's CMakeLists. Its sibling trace_replay_core.cpp is compiled verbatim by the
  DESKTOP mobilegl_trace_replay runner (tools/trace_replay/CMakeLists.txt names the
  same file), where <android/log.h> does not exist, so nothing Android-only may live
  there; spawn_spike.cpp carries an #error for anyone who adds it to that list.
- The Activity runs the spike, and nothing else, when launched with the
  `mobilegl_spike_spawn` intent extra; that mode needs no trace, no golden and no
  render surface. It is a separate JNI entry point rather than another parameter on
  the 30-argument replay call, which it shares nothing with.
- Not yet run on a device: both device locks are held by another campaign. The
  on-device verdict is the coordinator's step.
2026-09-05 20:49:57 -04:00
swung0x48 87ee17c68c [Docs] (Disaggregated): fold the P0 measurements and corrections into the plan
- GL_COMPUTE_WORK_GROUP_SIZE is answered by MG_Impl from ProgramObject::GetComputeLocalSize, not by a backend; only the compute limits are caps, and the two dead table entries (GetInteger64i_v, GetProgramiv) were retired in P0
- the glRenderbufferStorage OOM-probe idiom appears in 0 of 41 fixtures, so kNeedsAck is carried by glBufferStorage only
- FramebufferSrgb/DepthClamp: six readers of a constant false and zero readers respectively, no fixture enables either; recorded as a decision to take before the render-state chunk table freezes
- the call catalogue is 68 unique records (screen 10, ctx-query 6, CSO 13, kCtxState 17, kCtxObject 9, kCtxVerb 13); PipeCalls.def is the single source of truth and the wire opcode is a line's position
- measured layouts (MGPDrawInfo head 56 B, RenderStateParameters 1168 B, ResidualValueBlock 1248 B, MGPipeContext 464 B ...), the 926/73 MG_Impl mutator surface, the first per-draw accessor numbers on lavapipe (Espryt 20.65, Magma 15.54), the EndTransformFeedback null-as-capability trap, and the host-side spike results
2026-09-05 20:29:30 -04:00
swung0x48 aa005720d0 [Test] (MG_Remote, Wire): pin the hung-up doorbell, the wakeup that must not be eaten, the ring's capacity ceiling and the publish-then-ring handoff
- FdPassingTest.SocketDoorbellStopsParkingWhenThePeerHangsUp builds a
  SOCK_STREAM socketpair - deliberately not FdPassing::CreateSocketPair's
  datagram pair, because only a stream end reports the hangup at all - closes
  the notifier, and asserts Park returns false, latches Dead(), stays latched,
  and that a Wait with kWaitForever gives up in under a second instead of
  spinning.

- FdPassingTest.SocketDoorbellStillDeliversTheLastRingBeforeAHangup rings and
  then closes: detecting death must not swallow the wakeup already sitting in
  the socket buffer, since the peer's last publish is the one a waiter is most
  likely to be blocked on.

- InProcessTransportTest.AFrameWakeupIsNotEatenByAWaiterOnDescriptors blocks
  two readers on one endpoint with two different predicates and requires the
  frame to arrive within 2s rather than "eventually, when a receive timed out".

- RingTest.RejectsACapacityTheRecordHeaderCannotDescribe refuses 4 GiB from
  both roles without mapping anything (the constructor rejects before it
  touches the base pointer) and keeps 2 GiB accepted as the positive control.

- RingTest.DoorbellHandoffWakesBothSidesOnEveryPublish runs 2000 records
  through the ring with real parking in both directions, in the publish-then-
  NotifyIfParked order the fences assume. It cannot prove the fence pairing -
  no test can, since x86 has to actually hold the release store in the store
  buffer across the flag read - but it exercises the exact call order, and a
  lost wakeup surfaces as a Wait that times out with work available (a red
  test) rather than as a hung CI job.

- Result: 1429 unit tests pass with MOBILEGL_BUILD_DISAGGREGATED=ON (1424
  before this commit; the wire subset is 47), 1382 pass with it OFF, and the
  same three pre-existing skips appear in both. Every new case was verified to
  fail with its fix reverted and to pass again with it restored.
2026-09-05 20:16:50 -04:00
swung0x48 c1a7ffac94 [Fix] (MG_Remote, Transport): give descriptor offers their own condition variable, cap the ring at what a 32-bit size field can describe, and keep the frontend umbrella out of Framing.h
- InProcessChannel::Direction served two different predicates from one
  condition_variable signalled with notify_one, so a SendFrame wakeup could be
  delivered to a thread blocked in ReceiveFd, which re-tests its own predicate
  and goes back to sleep - leaving a queued message undelivered until some
  unrelated later event. ITransport narrows the contract to one dedicated
  reader thread, but that is a comment, not a mechanism, and the first caller
  that splits its reader should not have to discover this. Offers now have
  their own fdCv, and Close() notifies both.

- RingProducer/RingConsumer accepted any power-of-two capacity while
  RingRecordHeader::size is 32-bit by wire contract (plan section 8.1 ->
  PLAN.md section 6.3: 8-byte RecHeader). At 4 GiB or more a record's size -
  or a wrap filler's, which is sized by the distance to the boundary - would be
  truncated on the way in, and the consumer would then bounds-check the
  truncated value against the real one. kMaxRingCapacity rejects that at
  construction, the same class of guard as the power-of-two and
  smaller-than-a-header checks beside it. Unreachable today (SEG_CMD 8 MiB,
  SEG_STAGE 32 MiB), which is the point of catching it now.

- Framing.h included MG_Util/Debug/Log.h, which includes Includes.h, the GL
  frontend's umbrella header: 661 headers by `clang++ -H`. It is the one header
  under Transport/ that broke the rule ITransport.h states for this layer
  ("nothing about a byte pipe needs the GL frontend's umbrella header"), which
  matters when the server-side binary links this and when the include-graph
  purity gate of plan section 10.3 (gate A, asserted on -H output rather than
  on symbols) lands. Its three error paths now call WireLogError, declared in a
  new dependency-free WireLog.h whose .cpp owns the umbrella. Framing.h is down
  to 134 headers, none of them MG_State, Includes.h or Log.h.

- Two documentation corrections. ITransport::Shutdown documented a one-sided
  "releases the endpoint" while InProcessTransport::Shutdown closes both
  directions - which is what closing a socket does, so the spawn transport will
  behave the same way; the interface now says whole-connection teardown, and
  keeps the promise that queued messages stay readable until drained.
  InProcessTransport.h cited a CMake option
  MOBILEGL_BUILD_DISAGGREGATED_INPROC that grep finds nowhere: plan appendix B
  reserves it for the role-isolation shim, this skeleton does not add it, and
  the delivery mode is a runtime choice (MOBILEGL_TRANSPORT), not a build one.

- Evidence: both configurations reconfigured and rebuilt; nm --defined-only on
  the OFF build still reports 0 MG_Remote symbols and the ldd dependency set is
  byte-identical to the OFF link (plan section 10.3, the two surviving
  byte-level equalities). Negative controls: removing the capacity ceiling
  makes RingTest.RejectsACapacityTheRecordHeaderCannotDescribe fail on both
  roles; collapsing fdCv back into cv makes
  InProcessTransportTest.AFrameWakeupIsNotEatenByAWaiterOnDescriptors fail at
  3950ms against its 2000ms bound.
2026-09-05 20:16:50 -04:00
swung0x48 bdd4bed431 [Fix] (MG_Remote, Transport): close the doorbell's lost-wakeup window with two seq_cst fences and stop a hung-up peer turning every park into a spin
- The header claimed the park flag's own seq_cst store and load closed the
  lost-wakeup window. They cannot: that Dekker argument needs all FOUR accesses
  in the seq_cst total order, and the other two are not in it - the watermark
  publish is a release store (RingProducer::Publish) and the condition re-test
  is an acquire load. There was no atomic_thread_fence anywhere under
  MG_Remote. On x86 a release store and a seq_cst load are both plain MOVs, so
  the notifier can read parked==0 while its head store still sits in the store
  buffer, and the waiter then parks on a stale watermark forever; ARMv8
  survived only because STLR->LDAR is RCsc, which is luck, not the design.
  Doorbell::Wait now fences after announcing and NotifyIfParked fences before
  reading the flag - the pairing the standard actually guarantees
  ([atomics.order]) - and the header says so, including the other half of the
  contract: publish the watermark BEFORE ringing, because a fence only orders
  what precedes it. This is the claim the whole P5/P6 wait discipline (present
  credit, kNeedsAck blocking requests, full-ring escalation) will be built on,
  and its failure mode is a silent cross-process hang.
  Inherited design, plan section 8.1 (PLAN.md section 6.2/6.2a: bidirectional
  doorbell, MOBILEGL_IPC_SPIN_US default 50us, condvar for inproc).

- SocketDoorbell::Park treated any poll() return > 0 as a wakeup and never
  looked at revents. Measured on this machine: an AF_UNIX SOCK_STREAM
  socketpair whose peer has closed returns revents=POLLIN|POLLHUP with
  recv()==0 immediately and forever. Park therefore returned true, Wait stored
  parked=0, found its condition still false and re-parked - so a waiter with
  kWaitForever burned a big core at full clock with no bound. That is exactly
  the pathology the bidirectional doorbell exists to prevent (a whole 16.6ms
  frame of a big core on a phone, competing with the GPU and the game's JVM),
  reached from the other side. Park now branches on revents, a new Drain()
  latches death on EOF (and on ECONNRESET/EPIPE from Notify), and the new
  Doorbell::Dead() lets Wait give up instead of re-parking on a descriptor that
  can never deliver another wakeup.

- Same commit, same defect class: `fd` is documented as one end of an AF_UNIX
  socket pair, not "a socket or pipe end". Notify uses send(MSG_DONTWAIT|
  MSG_NOSIGNAL) and Park uses poll()+recv(), which a pipe end refuses with
  ENOTSOCK, and a SOCK_DGRAM pair reports no readiness at all when the peer
  closes (measured), so the spawn transport wants SOCK_STREAM.

- Evidence: build-linux-split rebuilt clean; the wire suite is green (47/47).
  Negative control - reinstating the revents-blind Park makes
  FdPassingTest.SocketDoorbellStopsParkingWhenThePeerHangsUp fail on both Park
  assertions, and restoring this code turns it green again.
2026-09-05 20:16:49 -04:00
swung0x48 10315e71f3 [Test] (MG_Remote, CI): cover the wire layer with five suites and gate the generated header with flatc-check
- MobileGL/MG_Test/Wire, 42 gtest cases in five binaries, ctest label `unit`, registered only when MOBILEGL_BUILD_DISAGGREGATED is ON so the default configuration is untouched.
- FdPassingTest is the one the earlier branch could not have had: it forks a child that creates a shared segment, fills 64 KiB with a pattern and hands the descriptor over SCM_RIGHTS; the parent adopts it, maps it read-only and compares every byte. Everything in Feat/CS-Delta-IPC ran in one process, which is why `out->fd = -1` survived unnoticed. It also pins that a too-small sideband buffer is refused before the datagram is consumed (so no descriptor is dropped), that an empty sideband still carries its fd, and that a receive with no offer times out. The `spawn` SocketDoorbell is covered in the same file because it rides the same kind of socket: a parked waiter woken through NotifyIfParked, a clean timeout, and a wakeup that arrives before anyone parks being remembered and then consumed exactly once.
- RingTest: control-page size/alignment/cache-line layout, a non-power-of-two capacity refused, payload alignment, 200 records driven through a 256-byte ring so the wrap filler path runs repeatedly and no record ever straddles the boundary, backpressure (full ring refuses, applied alone frees nothing, retired frees), a record larger than the ring refused, the generation bump refused while records are in flight and accepted once quiesced, a corrupt header refused instead of dispatched, and a 20000-record two-thread producer/consumer run checking order, content and the final cursor equality.
- FramingTest: byte-at-a-time reassembly of two frames, the magic reading "MGLF" on the wire, empty payloads, a bad magic and an oversized length each latching the reader dead (the old code hung silently instead), the send-side cap, and the buffer-too-small contract keeping the message so the retry still finds it.
- InProcessTransportTest: both directions, ordering, buffer-too-small, poll and timeout, shutdown draining queued messages before it closes, a blocked receiver woken by a send and by a shutdown, the frame cap, descriptor hand-off with its sideband, and three condvar doorbell cases - a parked waiter woken through NotifyIfParked, an already-true condition that must never park, and a clean timeout.
- ProtocolSmokeTest: a Hello built and read back through the committed generated header, a Welcome carrying the four segment announcements, the same buffer travelling across the transport unchanged, a truncated buffer failing verification rather than being read, and the CtrlMsg / SegmentKind / LogLevel / FatalCode tag values frozen - they are wire numbers, so if that test has to be edited the schema change was a wire break.
- CI: a `flatc-check` job in test.yml that checks out only the flatbuffers submodule, builds the pinned flatc through scripts/gen_protocol.py into the runner temp directory, regenerates and runs `git diff --exit-code` on protocol_generated.h. It needs no MobileGL build, so it does not depend on build-linux (plan B section 8.1 / earlier section 7.1: the committed header plus a CI regeneration check, and no flatc in the default build graph).
- Verified: `ctest -L unit` is green in both configurations - 1382 cases with the option OFF and 1424 with it ON (the same 1382 plus these 42); the nm gate is 0 matches OFF and 93 ON; regeneration of protocol_generated.h is byte-identical, and the flatc-check gate goes red on a hand-edited header and green again after a clean regeneration.
2026-09-05 20:16:49 -04:00
swung0x48 bfa087d0f7 [Feat] (MG_Remote, Transport): land the transport skeleton behind MOBILEGL_BUILD_DISAGGREGATED - framing, the SPSC ring, doorbells, shm segments and SCM_RIGHTS
- New CMake option MOBILEGL_BUILD_DISAGGREGATED (default OFF, plan B appendix B). ON appends the MG_Remote sources to SOURCE_FILES, puts 3rdparty/flatbuffers/include on the include path and defines MOBILEGL_BUILD_DISAGGREGATED=1. OFF compiles nothing from MG_Remote and adds no include path and no library, which is one of the two byte-level equalities plan B section 10.3 keeps: measured `nm --defined-only build-linux/libMobileGL.so | grep -ic MG_Remote` = 0 with the option OFF and 93 with it ON, with an identical ldd set in both configurations.
- The option forces itself OFF with message(WARNING) when 3rdparty/flatbuffers/include/flatbuffers/flatbuffers.h is missing, so a checkout without the submodule still configures and builds rather than failing a hundred lines later on a missing header.
- ITransport.h: SendFrame / ReceiveFrame / PeekFrameSize / ShareFd / ReceiveFd / Shutdown. ReceiveFrame's contract is the fix for a defect of the earlier branch: a destination buffer smaller than the pending message returns MOBILEGL_ERR_BUFFER_TOO_SMALL with the required size and KEEPS the message queued. The earlier transport failed the call and popped the message anyway, which wedges the stream permanently the first time a reader guesses a size wrong.
- Framing.h: [u32 'MGLF'][u32 len][payload], 64 MiB cap, validated on read. A bad magic or an oversized length latches the reader dead, is logged at ERROR and makes every later call return MOBILEGL_ERR_PROTOCOL_MISMATCH. This is the second inherited defect: Feat/CS-Delta-IPC's Framing.h:41-45 Feed() always returned OK and its header peek merely returned false, so a desynchronized stream became a silent permanent hang; its LocalSocketTransport.cpp:232-236 then allocated on the peer-supplied length with no cap. The magic is also byte-ordered so it reads "MGLF" on the wire, where the earlier constant spelled "FLGM".
- Ring.h/.cpp: RingControl exactly as the inherited design (plan B section 8.1 -> earlier section 6.2): two independent cursor triples (cmd and stage, each {head, appliedTail, retiredTail}), appliedSeq / submittedSeq / retiredSeq / completedFrameSerial / presentAckSerial, serverEpoch, ringGeneration, consumerParked, producerParked, eventRingFull, eventDropped. One 4096-byte page with each contended group on its own cache line, pinned by static_assert on size and alignment and by an offset test.
- The SPSC pair uses monotonic byte cursors and a power-of-two mask, so a torn read can never look like a valid earlier position. A record never straddles the wrap: the producer emits a kPad filler to the boundary, which is always a multiple of 8 and therefore always has room for a header. The producer reclaims against retiredTail rather than appliedTail, so the day the server borrows a ring slot into the GPU timeline (kRecBorrowSlot) it does not silently degrade to early recycling. HardDrainRing bumps ringGeneration only when the ring is quiesced, and leaves the cursors monotonic so cached offsets are recognisably stale.
- The consumer bounds-checks every record header before dispatch (8-aligned, at least a header, no larger than what the producer published, contiguous inside the mapping) and reports corruption to the caller instead of dispatching into undefined behaviour. That is the runtime half of the earlier section 6.3 discipline: SEG_CMD is written by another process, so a compile-time static_assert on record sizes proves nothing about what is in the mapping.
- Doorbell.h/.cpp: spin then park, both directions (earlier section 6.2a). CondVarDoorbell for inproc, SocketDoorbell for spawn (one byte, codes 0x01 ring-advanced and 0x02 watermark-advanced) - no futex, eventfd or named event anywhere. The lost-wakeup window is closed by ordering: the waiter stores its park flag seq_cst and then re-tests the condition, NotifyIfParked loads the same flag seq_cst after the watermark is published, so one of the two always sees the other. kDefaultSpinUs is 50, the MOBILEGL_IPC_SPIN_US default.
- ShmSegment.{h,cpp} + ShmSegmentPosix.cpp: memfd_create by raw syscall on desktop Linux (the glibc wrapper is too recent to rely on), ASharedMemory_create on Android (API 26; libc's memfd_create wrapper is API 30, above MobileGL's floor), shm_open + immediate shm_unlink as the fallback. Adopt() refuses a descriptor whose fstat size is smaller than the size the peer announced, so a short segment cannot turn every later offset into an out-of-bounds map. ShmSegmentWin32.cpp (CreateFileMappingW in Local\) is compile-guarded and untested - this project's Windows machine is not a correctness gate.
- The Android path is compile-verified, not just written: an arm64-v8a NDK build with the option ON links, ShmSegmentPosix.cpp.o carries an undefined ASharedMemory_create, and ShmSegmentWin32.cpp.o is empty there. That build is also what caught the missing <cstddef> in ShmSegment.h and Framing.h, where std::size_t / std::ptrdiff_t only resolved through a transitive include on the host sysroot.
- FdPassing.{h,cpp}: SCM_RIGHTS in the FIRST transport commit, as plan B section 8.1 demands, over a dedicated AF_UNIX SOCK_DGRAM socketpair rather than the control byte stream (message boundaries survive on every POSIX - SOCK_SEQPACKET does not exist on macOS - and ancillary data can never be split from its payload). The third inherited defect this replaces: Feat/CS-Delta-IPC deferred fd passing and hardcoded `out->fd = -1` in LocalSocketTransport.cpp:296, so on the only platform that matters its data plane could not move one byte between processes. MSG_CTRUNC, an unexpected descriptor count and a malformed sideband header all close every descriptor received before failing, and a too-small sideband buffer is refused before the recvmsg so a datagram is never half-consumed.
- InProcessTransport.{h,cpp}: two in-memory queues plus the condvar doorbell pair. It keeps the frame size cap so nothing that passes in inproc becomes illegal after the switch to spawn, hands descriptors over with dup() under the same ownership rule as SCM_RIGHTS, and lets a peer's queued messages be drained after Shutdown - usually the last one says why it is going away.
- Not done here on purpose: the MOBILEGL_IPC_* environment variables are parsed in ConfigLoader.cpp, which belongs to another P0 work package running in parallel; this commit only exposes the constants (kDefaultSpinUs, kMaxFramePayloadSize, FdPassing::kMaxSidebandBytes) so that plumbing has something to set.
2026-09-05 20:16:49 -04:00
swung0x48 a1e22c26ab [Build] (MG_Remote, Protocol): pin the flatbuffers submodule, add the control-plane schema and commit its generated header
- 3rdparty/flatbuffers submodule pinned to the latest release tag v25.12.19 (7e163021). The runtime is header-only, so only 3rdparty/flatbuffers/include is ever used and no library is linked; CMake never calls add_subdirectory on it and flatc is not in the build graph (plan B section 8.1, inheriting the earlier plan's section 7.1).
- MobileGL/MG_Remote/Protocol/protocol.fbs carries the CONTROL PLANE only: SegmentRef, Hello, Welcome, CapsSnapshot, DefaultFramebufferInfo, SurfaceOp, SurfaceReply, ResyncRequest, ResyncDone, AuxRequest, Fatal, LogLine, union CtrlMsg and the CtrlEnvelope root with a file_identifier. Hot-path records are FlatBuffers structs generated from MG_Pipe/PipeCalls.def in a later package and are deliberately absent here, so record numbering never churns.
- Two deviations from the earlier plan's section 7.1 sketch, both deliberate: (a) ProgramReflection is not a union member - plan B ships program artifacts inside the create_shader_state CSO blob (section 8.2), and union tags are wire values that may only ever be appended, so reserving a tag for a message that may never exist is worse than appending one later; (b) maxComputeWorkGroupCount/Size are vectors, not [int:3] - fixed-size arrays are legal only in FlatBuffers structs, never in tables.
- scripts/gen_protocol.py resolves flatc as MOBILEGL_FLATC_EXECUTABLE, otherwise builds the pinned flatc ONCE into <repo>/../flatc-build (override with MOBILEGL_FLATC_BUILD_DIR), outside the project build graph. A flatc found on PATH is deliberately refused and a version mismatch against the pinned runtime is a hard error: the generated header static_asserts FLATBUFFERS_VERSION, so a stray flatc either fails to compile or churns the committed file on every machine. The earlier branch did the opposite - Protocol/CMakeLists.txt:22-38 add_subdirectory'd the FlatBuffers tree with FLATBUFFERS_BUILD_FLATC=ON whenever MOBILEGL_FLATC_EXECUTABLE was unset, which is exactly the NDK trap it claimed to avoid (cross-compile an arm64 flatc, then run it on the host).
- protocol_generated.h is committed with the project source header prepended by the generator, so regeneration is byte-identical: verified by running gen_protocol.py twice and by perturbing the file and regenerating it back.
- Reuse from Feat/CS-Delta-IPC: MobileGL/Protocol/mg_protocol_base.h, kept as the shared C vocabulary (result codes, byte spans, shm region, id typedefs) and keeping the structSize-first versioning discipline that section 14.2 calls the answer to risk B-R10. Dropped from it: MobileGLObjectKind / MobileGLObjectScope / MobileGLObjectHandle - plan B never puts GL object identity on the wire (the frontend allocates {slot, generation} handles in MG_Pipe, section 4.2.1), so a second identity vocabulary would be a drift surface with no reader. Added MOBILEGL_ERR_BUFFER_TOO_SMALL as an append-only code for the receive contract.
2026-09-05 20:16:49 -04:00
swung0x48 bd2b4158e0 [Fix] (DirectVulkan, State): key the transform-feedback counter slots on the object's identity, not on its recycled GL name
- VulkanRenderer::CurrentXfbCounterSlot keyed m_xfbCounterSlotByObject on
  GetBoundTransformFeedbackName(). glGenTransformFeedbacks hands a deleted name
  straight back (IndexGenerator is LIFO) and nothing ever removed a map entry, so
  a transform feedback object created on a recycled name was served the DEAD
  object's counter group - and with it that group's m_xfbCountersValid and
  m_xfbLastSeenGeneration entries, which are the resume/fresh decision for
  vkCmdBeginTransformFeedbackEXT. This is D21 in plan B v2 4.7.3, the one entry
  in that table whose today-key "guards nothing", and 10.4-5 asks for it to land
  on dev on its own - hence this separate commit, kept in files no other commit
  on this branch touches so the cherry-pick applies unaided.
- Frontend: TransformFeedbackObjectState gains a never-reused `lifetimeId`
  through a default member initialiser, so every route into existence
  (operator[] materialisation, `= {}` in GenTransformFeedbackNames and
  CreateTransformFeedbackObject) mints a fresh one and a recycled name cannot
  carry the dead object's id back. The allocator is the same shape as
  BufferObject::AllocateLifetimeId (atomic, starts at 1 so a zeroed backend slot
  is never a live object).
- The bound object's id is mirrored in m_boundTransformFeedbackLifetimeId,
  refreshed by RestoreBoundTransformFeedbackState - which every bind, and the
  revert that deleting the bound object performs, goes through - and seeded for
  the default object by the GLContext constructor. GetBoundTransformFeedback
  LifetimeId is therefore a const load. Reading it through operator[] instead
  would have been an INSERT on the per-draw path, and UnorderedMap is
  ska::flat_hash_map, whose rehash invalidates every reference into the
  container, not just its iterators.
- Backend: the UnorderedMap is replaced by a fixed 16-entry owner table, which
  fixes the second half of the same defect - the map was keyed on a value that
  recycles yet was never pruned, so it grew for the life of the context. With
  lifetime ids as keys a map would have grown without bound instead, so the
  bounded table is required, not cosmetic.
- Slot exhaustion: past sixteen owners a group has to be taken over, and the
  victim is chosen among owners with NO OPEN SPAN, which
  GLContext::HasOpenTransformFeedbackSpan answers; an identity no live object
  carries any more answers false, and that is what lets a dead owner's group
  come back. Least-recently-used ALONE would have been exactly the wrong rule:
  GL only permits another object to capture while this one is PAUSED, so the
  paused span these groups exist to protect is by construction the least
  recently used entry, and an LRU takeover would reset the one resume offset
  that still matters. LRU is now only the tie-break among reclaimable groups.
  Sixteen genuinely open spans at once is reported (MGLOG_E_ONCE) rather than
  resolved silently, because whatever is taken then restarts at offset 0.
- Not done, and why: the natural place to hand a group back is
  glEndTransformFeedback, but registering DirectVulkan's EndTransformFeedback
  table entry would flip the test FixupGsStripCaptureOrder makes of that same
  pointer (GL_Drawing.cpp:1255) to decide whether the backend already captured
  in GL's vertex order, silently disabling the geometry-stage strip fixup for
  DirectVulkan. Giving that discriminator a name of its own is a separate
  change; until then the no-open-span rule is what keeps the table honest.
- CurrentXfbCounterSlot asserts the identity is never 0. Zero is the free-slot
  sentinel, so an identity of 0 would match every free slot as "mine" without
  ever claiming one - this bug reintroduced, with no symptom at the call site.
- TransformFeedbackLifetimeIdTest, in its own translation unit, pins the
  frontend halves: an object created on a recycled name must not report the dead
  object's id, the default object has an identity before anything binds it, and
  a PAUSED span still reads as open while another object is bound and capturing
  - which is the whole correctness argument for the eviction rule. The name
  reuse is not simulated: the test asks the real generator and skips (loudly) if
  it never recycled. Still untested: the >16-owners path itself, which needs a
  backend scenario with seventeen capturing objects and there is none.
- Negative controls, each applied then reverted: making a non-bound object's
  span read as closed reddens APausedSpanStaysOpenWhileAnotherObjectCaptures;
  making a vanished identity read as open reddens the same case on its delete
  assertion; dropping the constructor's seeding reddens
  AnObjectAtARecycledNameCarriesAFreshLifetimeId.
- Tested: cmake --build build-linux -j 24 (clean, 166 targets); ctest -L unit
  -j 12 -> 1386/1386 passed; ctest -L integration-gpu -> 866/866 passed
  serially, and 866/866 on one of two -j 8 runs. The other -j 8 run failed
  DirectGLES.PointSizeDemotionScenario.TheDemotionIsActuallyArmedWhenTheEnviron
  mentPinsItOn, a member of the pre-existing parallel-ctest flake family: it
  passes in isolation here, and the unmodified parent tree
  (~/w7/p0-noop-wins-base) reproduces the same family under -j 8.
2026-09-05 20:16:49 -04:00
swung0x48 9c7339b214 [Feat] (State): give RenderbufferObject the never-reused lifetime id every other cache-keyable state object already has
- RenderbufferObject was the last state object a backend twin registry keys on
  that could only be identified by its heap address or its GL name - both of
  which recycle. BufferObject, VertexArrayObject and ProgramObject all carry a
  process-wide, never-reused id for exactly this; the renderbuffer's absence is
  named in plan B §10.4-5 as one of the two latent problems P0 closes.
- Mirrors BufferObject.h:202-208 / BufferObject.cpp:19-24 verbatim in shape: a
  private static AllocateLifetimeId() over a namespace-scope
  std::atomic<Uint64> starting at 1 (so a zero-initialised memo slot can never
  carry a live object's id), a const member initialised from it at construction,
  and an inline const getter. The doc comment is the buffer one restated for the
  renderbuffer's own recycling sources.
- Deliberately NO GetVersion(): plan B §11 P0 says the id only. A mutation
  counter would be a second, independent invalidation surface to keep correct,
  and nothing needs one yet - the renderbuffer's mutable content already reaches
  the backends through AllocateStorage / SetInternalFormat / SetSamples.
- No caller yet, by design: the id exists so §4.7.3's D1 rekey (and the
  DirectGLES renderbuffer twin registry at Managers.h:1858) has something to key
  on. It is a pure addition - no existing field, signature or answer changes.
- ObjectLifetimeIdTest gains the two cases the other two object types already
  have, so the renderbuffer is covered by the same allocator-reuse probe: an
  object rebuilt at a freed address must not answer to the dead one's id, and
  two live ones must differ.
- Tested: cmake --build build-linux -j 24 (clean); ctest -R ObjectLifetimeId ->
  6/6 passed (4 pre-existing + 2 new).
2026-09-05 20:16:49 -04:00
swung0x48 50815a232e [Fix] (Backend, Getter): retire the two frontend queries that were never asked and strip the unreachable frontend arms from the third
- GLFunctionsTable had three entries that are frontend queries wearing a
  backend interface (plan B v2 §2.1(a)): GetIntegeri_v, GetInteger64i_v and
  GetProgramiv. Two of them have NO caller at all - `grep -o
  'gBackendFunctionsTable\.GL\.[A-Za-z_0-9]*'` outside MG_Backend/ lists 69
  distinct entries and neither GetInteger64i_v nor GetProgramiv is among them -
  so both table slots, both backends' implementations and both registrations are
  deleted here. This is plan B §11 P0's first "strictly no-op free win".
- Nothing was moved into MG_Impl, because MG_Impl already answers all of it.
  glGetInteger64i_v is served by GL_Getter.cpp:1240-1314, which handles the
  indexed buffer queries itself and derives every other pname from its own
  GetIntegeri_v ("Handing the leftovers straight to the backend instead made
  glGetInteger64i_v disagree with glGetIntegeri_v on the very same pname").
  glGetProgramiv is served by GL_Program.cpp, whose GL_COMPUTE_WORK_GROUP_SIZE
  arm (:928-946) reads ProgramObject::GetComputeLocalSize - a link artifact of
  the program the APPLICATION wrote, which is the only program in the
  application's namespace. §4.7.1 class B.
- GetIntegeri_v stays, but only for what a backend genuinely owns. Its pure
  frontend arms were unreachable: GL_Getter::GetIntegeri_v answers
  GL_SHADER_STORAGE_BUFFER_{BINDING,START,SIZE} through
  TryDecodeIndexedBufferQuery (:991-1029) and the six GL_IMAGE_BINDING_* pnames
  at :1115-1153, and returns before touching the table. That left 9 dead cases
  in DirectGLES.cpp and 9 in DirectVulkan.cpp - the plan's "15" undercounts the
  two files separately. What still arrives is GL_MAX_COMPUTE_WORK_GROUP_COUNT /
  _SIZE (GL_Getter.cpp:1161-1177, and MG_Util/ShaderTranspiler/CompileEnv.cpp
  :134-138 asks the table directly), so DirectVulkan keeps exactly those two and
  DirectGLES becomes a plain driver passthrough.
- The dead arms were also WRONG, which is why deleting rather than reconciling
  them is the strict no-op: they clamped a bound range's size to the buffer's
  current storage, while the frontend reports the size glBindBufferRange was
  asked for verbatim (GL 4.6 core tables 23.4/23.5 - the clamp answered 0 for
  KHR-GL43.shader_storage_buffer_object.basic-binding's shape). Had a later
  refactor made the table the answer, the regression would have been silent.
- ProgramResourceCache::computeWorkGroupSize and the spirv-reflect entry-point
  loop that filled it go with DirectVulkan's GetProgramiv; nothing else read it.
- Three cases added to AdvertisedLimitsScenario pin what the frontend answers,
  on both lanes: the indexed SSBO binding/start/size on the 32- and 64-bit
  widths INCLUDING a shrink of the store underneath the binding (the arm that
  actually separates verbatim from clamped), the six image-unit pnames on both
  widths, and the compute local size plus the INVALID_OPERATION a program with
  no compute stage must give.
- Both new gates were shown to go red for their reason: making
  GL_COMPUTE_WORK_GROUP_SIZE answer a defaulted (1,1,1) fails
  ComputeLocalSizeComesFromTheLinkedProgram on both lanes, and re-introducing
  the deleted store clamp in GL_Getter fails
  IndexedBufferBindingsAreReportedVerbatimOnBothWidths on both lanes.
- Tested: cmake --build build-linux -j 24 (clean); ctest -L unit -j 12 ->
  1382/1382 passed; ctest -R AdvertisedLimits -> 18/18 passed (6 pre-existing +
  3 new, x DirectGLES and DirectVulkan).
2026-09-05 20:16:49 -04:00
swung0x48 d380a01f32 [Fix] (Metrics, DirectGLES, DirectVulkan): stop the summary line printing window totals under a per-frame label, and wire the six staging paths the site inventory claimed were covered or absent
- A window with no Present divided by a faked 1 and printed the window TOTALS under
  "bytes/f[...]": the *MultiDraw* slice (47 draws, no present) reported 1,404,550 bytes as a
  PER-FRAME figure, a 47x overstatement of exactly the SEG_STAGE sizing input plan B section
  8.2 / section 11 P0 asks this package to produce. FormatWindowLine now relabels the bracket
  to "bytes[...]" and prints draws/f=n/a when the window holds no frame; acc/draw=n/a follows
  the same rule, because "0.00" beside a non-zero acc= is the same lie. Pinned by
  PipeStatsTest.SummaryLineSurvivesZeroFrames and the reworked SummaryLineSurvivesZeroDraws.
- Every per-frame and per-draw field now goes through one FormatFixed2 helper. draws/f read 1
  for 26 draws over 14 frames (1.86) and buf read 97 for 1360 bytes (97.14): a systematic
  downward truncation of up to a whole unit on the figures the package exists to produce.
  Pinned by PipeStatsTest.PerFrameFieldsKeepTwoDecimals.
- FormatSummaryLine rewrote the window bases as a side effect of formatting, so any second
  reader silently zeroed the next window. Split into a pure FormatWindowLine() and an explicit
  AdvanceSummaryWindow(); EmitSummaryLine calls both. Pinned by
  PipeStatsTest.FormattingTwiceDoesNotConsumeTheWindow.
- Init() called ResetForTesting(), against the header's own "not used by any shipping path".
  Both now forward to an internal ResetCounters().
- TracyPlot published only the MISS half of each gate under the gate's unqualified name, so
  the headline output channel of section 11 P0 carried no denominator. Two series per gate now
  ("...-hit" / "...-miss") from static literal arrays. The payload histogram stays unplotted
  and says why: it is a run-total distribution over draws (section 4.5.7), not a per-frame
  scalar, and it reaches the operator through the JSON dump.
- GetOrCreatePipeline's 15-call tally sat ABOVE the list-topology primitive-restart refusal,
  so a declined draw added ten reads it never made - an OVER-count, which breaks the lower
  bound contract every other tally keeps. Moved to immediately before the payload build, and
  the enumeration reconciled with the constant: the excluded read is the sample-shading
  capability, short-circuited by m_sampleRateShadingFeatureEnabled.
- Six real staging paths were uncounted while the inventory claimed coverage. The inventory
  claim "DirectVulkan's own buffer staging ... has no second copy to count" was simply false.
  Now wired: MultiDraw.cpp's UploadScratch/UploadScratchRing (indirect commands, the compute
  tier's draw-info array, the rebased index stream - the class is passed in, so a new tier
  cannot forget it); Managers.cpp's pool-recycle reseed and the VBO-backed Float64 narrowing;
  VkBufferManager's eight host->device copies of buffer contents plus UploadTransient, the
  single chokepoint for Magma's per-draw vertex/index/indirect staging; VkTextureManager's
  packed staging slice, with the same box/rect SHAPE split Espryt already reported.
- New byte class stage-indirect-cmd, for draw PARAMETER bytes a backend synthesises and
  stages. Kept out of stage-index-client because these are the population that becomes MGPipe
  command-record payload (section 4.5.7), not resource bytes. A name addition, not a rename:
  no recorded baseline is invalidated.
- stage-ubo-named moved past the zero-copy direct-bind decision in ResolveUniformBufferPayload
  (a direct bind repacks nothing, so counting it there reported a copy that never happened),
  and Magma's default-uniform-block image now feeds stage-ubo-global the way Espryt's does,
  counted after the per-frame slice memo.
- The site inventory in PipeStats.cpp is rewritten to name every unwired path by file and
  function. An inventory that overstates coverage is worse than a missing counter, because
  the zero is then read as an answer.
- Evidence, lavapipe/llvmpipe, MOBILEGL_PIPE_STATS=1: the MultiDraw slice now prints
  "window=0 draws/f=n/a bytes[buf=1404550 ...]"; the indirect tiers
  (MOBILEGL_ESPRYT_MULTIDRAW_MODE=indirect|multiindirect) move icmd 0 -> 660; Magma's GuiBatch
  line moves from buf=0 tex=0 ubog=0 to buf=685.71 tex=41.14 ubog=157.71 with tex[emit=9
  box=9 rect=0 jobs=9]. Off-path A/B against the base tree with the env unset, 9 runs each of
  a 40-scenario draw slice, sorted totals in ms: Espryt base 389..794 (median 399) vs branch
  384..403 (median 394); Magma base 406..472 (median 410) vs branch 408..761 (median 414) -
  the always-compiled guard is below this harness's noise on both backends.
- 1410 unit tests green (15 PipeStatsTest). integration-gpu 860/860, 860/860, 859/860; the one
  failure is DirectVulkan.PointSizeDemotion...TheDemotionIsActuallyArmedWhenTheEnvironmentPins-
  ItOn, a member of the load-dependent *IsActuallyArmed* flake family already present in the
  untouched base tree, and it passes 8/8 standalone here. stdio gate and gen_pipe check green.
2026-09-05 20:16:49 -04:00
swung0x48 7566a0b002 [Feat] (DirectGLES, DirectVulkan): instrument the six memo gates, the staging byte paths and both Present hooks with the MGPipe counters
- The sites of plan B section 2.3.1, verified against dev@81b17c0b (the plan's own line
  numbers for DirectGLES drift by 4-9 lines; the DirectVulkan ones are exact):
  SyncRenderState is DirectGLES.cpp:1994 with the version read at :1998 and the
  early-out at :2007-2010 (plan says 2003 / 2007 / 2016-2018); SyncNeccessaryTextures
  at :1511 (plan :1520); CurrentUnitBindingsEpoch at :1412-1435 (plan :1418-1436);
  PrepareForDraw at :2907-2968 (plan :2916-2976); the global-UBO upload at :3355-3397
  (plan :3369-3392); TrySetupDrawFastPath :5994, GetOrCreatePipeline :4948-4993,
  ApplyDynamicDrawStateTail :5871-5893 and UniformManager ResolveUniformBufferPayload
  :2022/:2052 all as cited.
- Accessor counting is STATIC TALLIES at ten hot entry points, not a wrapper around the
  293 pGLContext-> sites: each instrumented function adds the number of accessor calls
  its own body made on the path taken. Reads inside callees, and every conditional read
  (the sRGB capability in SyncRenderState, the XFB probe and the version-gated parameter
  fetch in TrySetupDrawFastPath, the cull-mode/logic-op/tessellation reads in the
  pipeline payload builder) are excluded, so the number is a consistent LOWER bound. The
  full inventory of what is and is not counted is the header comment of PipeStats.cpp.
- The Magma fast-path gate is counted from SetupDraw, not from inside
  TrySetupDrawFastPath: that function has 27 decline returns and one success return, and
  counting at the caller is the only shape that cannot miss one.
- The texture upload counts the SHAPE (union box vs N-rect list) separately from the
  bytes, because SSIM is blind to the shape and the +6 ms/frame Mali regression of
  section 7.3 was a shape regression, not a byte one.
- Frame boundary: DirectGLES::Present after the ring upkeep, and DirectVulkan's backend
  Present rather than VulkanRenderer::Present - the latter has an early return for the
  no-usable-swapchain case, and a suspended frame is still a frame the counters close.
- Measured on lavapipe/llvmpipe with MOBILEGL_PIPE_STATS=1, GuiBatchScenario, 14 frames
  and 26 draws: Espryt 20.65 accessor calls per draw (gates ers 22/18, etl 71/9, eub
  71/9), Magma 15.54 (mfp 12/14, mpm 0/14, mdt 12/14). Both land inside the 10-25 band
  section 2.3.1 predicted and far below the 124/169 static counts, which is the
  correction that section was written to force.
2026-09-05 20:16:49 -04:00
swung0x48 42e0f47ebb [Feat] (Metrics): land the MGPipe boundary counters - bytes, dynamic accessor calls and the six memo gates, off unless MOBILEGL_PIPE_STATS is set
- Plan B section 11 P0 asks for TracyPlot per-frame counters on both sides of the
  boundary, and section 2.3.1 adds the deliverable v1 did not have: DYNAMIC call
  counters. The static call-site counts everyone quotes (Espryt 124 / Magma 169) are
  not the per-draw cost, because every one of those paths is memo-gated; without a
  dynamic counter P2's verdict stays a guess. The tree had no per-frame byte or call
  measurement at all - MG_Util/Metrics is format arithmetic, and Tracy has zones but
  no plots.
- Cost when off: g_pipeStatsEnabled is a plain global Bool latched once in
  Initialize() right after MG_ConfigLoader::Init(), and every counting site is
  `if (PipeStats::Enabled()) ...` - one load of a hot global and one never-taken
  branch. The counters are relaxed atomics because buffer and texture staging are
  reachable from more than one thread; the off path never touches them.
- Eight byte classes (stage-buffer, stage-texture, stage-ubo-global, stage-ubo-named,
  stage-vertex-client, stage-index-client, persistent-map-push and the
  residual-value-block placeholder of section 6.3), six call classes and the six memo
  gates of section 2.3.1, each as a hit/miss pair. Names are minted now, including the
  two that stay 0 in P0, so no recorded baseline is invalidated by a later rename -
  which is why the name set is pinned by a test.
- Reporting: TracyPlot per counter per frame when TRACY_ENABLE; with
  MOBILEGL_PIPE_STATS=1 one MGLOG_I summary line every 120 frames and at teardown, plus
  a JSON dump to MOBILEGL_PIPE_STATS_FILE when that is set. MGLOG_I against the usual
  "MGLOG_D for non-critical" rule on purpose: the line has to survive an INFO build -
  the only build a device runs - it is at most one line per 120 frames, and it exists
  only when the operator asked for it.
- Summary lines report disjoint WINDOWS, not run totals: a run total over a workload
  that changes shape (load, then steady state) averages away the very number section
  2.3.1 wants an absolute value for.
- MOBILEGL_PIPE_STATS_FILE joins the six switches parsed in e48a3582; like them it
  needs no allow-list entry because InitializeAcceptedEnvVariables accepts every
  MOBILEGL_-prefixed variable by construction.
2026-09-05 20:16:49 -04:00
swung0x48 9bbf71990c [Build] (CI): add the pipe-gen check, the stdio-instrumentation gate and the citation lint
- Three P0 gates from plan B section 11, in one job that needs no build: a broken build
  must not be able to hide a drifted interface.
- pipe-gen-check regenerates G1-G7 and runs git diff --exit-code over
  MobileGL/MG_Pipe/generated. Since all seven generators read the same .def files, this is
  what makes drift between them impossible rather than merely unlikely.
- The stdio gate refuses fprintf(stderr and printf( under MG_Backend and MG_State. Nothing
  there matches today, so it lands with NO whitelist - verified with a negative control
  that fprintf(stderr and std::printf both trip it while snprintf does not. Per-draw
  instrumentation has been committed by accident before, once inside a mutex critical
  section, and MGLOG_D is the channel these trees are allowed to use because it compiles
  out in INFO builds.
- scripts/gen_pipe_dirty_surface.py reports the frontend mutation surface corollary 4
  needs covered: 926 mutator calls under MG_Impl/GLImpl over 73 distinct mutators, of
  which only 92 sit in a function that also reaches the backend. The other 834 are
  published by the NEXT verb, which is precisely the population that needs an aggregate
  generation. Informational in P0; it becomes a gate in P1 when there is a mapping file to
  diff against.
- scripts/check_doc_citations.py resolves every `path:line` citation against a git
  revision. It reproduces the failure that motivated it - the plan's first draft cited
  SamplerObject.h:468-492 in a 160-line file - and reports 84 unresolved citations out of
  1028 in docs/Disaggregated today, which is why CI runs it warning-only until those
  documents settle. --strict exits 1, verified in both directions.
2026-09-05 20:16:49 -04:00
swung0x48 2f8d0f0d51 [Feat] (Config): parse the six MOBILEGL_PIPE_* switches
- Appendix B of plan B. All six default to today's behaviour: PipePush 0 is "pull
  everything", verify and stats off, legacy memos ON so the first handle waves keep a real
  old-versus-new arm (B-R16), texel retain 0 because MipmapStorage already holds a complete
  CPU shadow so that cache buys latency and never correctness (section 7.5c), index mirror
  64 MiB.
- No allow-list edit is needed and the header says why: InitializeAcceptedEnvVariables
  accepts every MOBILEGL_ / LIBGL_ prefixed variable found in the environment, so a name
  with that prefix is visible to these queries by construction - the failure mode of a
  hand-maintained accepted list does not exist here.
- PipeLegacyMemos defaults ON, so it is read as a tri-state (QueryEnvQuirkOverride !=
  ForceOff) rather than as a plain truthy flag: unset must keep the memos and only an
  explicitly falsy value may drop them. Reading it with QueryEnvFlag would have inverted
  the default silently.
- QueryEnvUint64 is added alongside QueryEnvUint32 for the subsystem bitmask, accepting a
  0x prefix - a bitmask written in decimal is unreadable - and warning and falling back to
  the default on anything unparseable, exactly like its 32-bit sibling.
2026-09-05 20:16:49 -04:00
swung0x48 3363258908 [Test] (MGPipe): pin the catalogue arithmetic, the wire opcodes and the comparator
- Thirteen cases that need no GL context and no driver, so the fact that PipeCalls.def and
  the seven generated files agree is checked on every unit run rather than at review time.
- The catalogue count is one fact stated three times - the macro expansion,
  MGP_CALL_LIST_DOCUMENTED_COUNT and the two generated tables - and the per-class counts
  documented in the .def header are asserted individually, which is what caught kCtxState
  being 17 rather than 18 (set_texture_params carries kCtxObject, per the plan's own
  example in section 4.1).
- An uninstalled pipe must be all-null: that is what "this subsystem has not been migrated,
  keep pulling" means (section 4.1), so it is asserted rather than assumed.
- The comparator test pins the two properties the verify harness depends on: the FIRST
  differing field is named, and padding is not a field - two payloads that differ only in
  padding compare equal, while a nested array element does not.
- The poison test pins the per-verb behaviour a bitmap cannot express: a field filled for
  verb N is stale at verb N+1.
2026-09-05 20:16:49 -04:00
swung0x48 9c773182bb [Feat] (MGPipe): land the interface skeleton - the complete call catalogue, the payload PODs and the seven generators
- Plan B section 4 makes the frontend/backend boundary explicit and gives the backend its
  own state machine. This is the P0 deliverable of section 11: the whole catalogue exists
  from day one, placeholders included, because the wire opcode is a call's position in
  PipeCalls.def and record numbering must never churn.
- MG_Pipe/PipeCalls.def is the single source of truth: 68 unique calls as
  X(Name, Payload, Class, Flags). Its header reconciles that number with the plan's
  headline counts (section 4.4, appendix A), which double count - "CSO 15" names
  bind_sampler_states and set_sampler_views that the "set_* 17" list also names, "screen
  14" tabulates the query family that section 4.3 assigns to the context, and the
  "transfer 12" row enumerates 11 calls. Each reconciliation is written down next to the
  count rather than resolved silently.
- MGPipeHandles.h: the 8-byte {slot, gen} pair, dense per-kind slots, the reserved null
  and default-framebuffer handles, and the ShaderCso composite band (sections 4.2, 5.6.3).
  The two generations are documented as strictly separate, with the interface rule that no
  call may require the client to know MGGen.
- MGPipeTypes.h: every payload of section 4.5 as a flat POD with explicit padding, a
  trivial-copyability assertion and an exact sizeof assertion, because the wire records are
  memcpy'd and a field silently changing width is a protocol break no test would see.
  MGPCaps embeds DynamicBackendParameters by inclusion so a caps field added there needs no
  second edit here; its assertion is stated as a composition because that struct still
  carries SizeT. ResidualValueBlock is pinned at MGL_RESIDUAL_BLOCK_SIZE 1248, the ratchet
  that only ever goes down and reaches static_assert(... == 0) in P13 (section 6.3).
- MGPipeHostSpan.h keeps the one shape that changes with the transport isolated behind one
  predictable branch, with the kFromServerIndexMirror sentinel D-B7 needs.
- MGPipeCallbacks.h names the reverse channel as ten callbacks plus the forward terminator
  in the context table, replacing 95 poke sites across 17 methods (section 7.1).
- scripts/gen_pipe.py runs G1-G7 off those .def files. G1 asserts each table is EXACTLY its
  call count of function pointers; G3 pads every wire record to the stream's 8-byte
  granularity and checks size >= sizeof && size <= remaining && size % 8 == 0 before
  dispatch, fatally; G4 compares field by field (padding excluded, floats by bits) because
  a comparator with false positives is one nobody reads - DirectGLES.cpp says the same
  thing about its own memcmp of RenderStateParameters; G5 turns the accessor list into
  per-verb poison generations rather than a written-once bitmap, which is the only version
  that can see a field left over from the previous draw (section 6.2.2); G6 joins the 477
  read points of the vendored backend_read_inventory.md against Coverage.def and reports
  0 UNMAPPED (299 to a call, 167 signatures that become handle parameters, 6 reverse
  channel, 5 client-resolved); G7 pins the pipeline subset BY MEMBER NAME from what
  VulkanRenderer::ComputePipelineStateHash hashes today, computing no offsets in python.
- The generated files are committed so the build never depends on python; CI regenerates
  and diffs them.
2026-09-05 20:16:49 -04:00
swung0x48 50fb13430f [Merge] (MG_State, ShaderTranspiler, tools/cts): land the write-map landing and open-ended fp64 storage block fixes that take KHR-Single-GL45.subgroups to 100% on Magma 2026-09-05 05:43:34 -04:00
swung0x48 d4f8adcf6d [Tools, Test] (tools/cts, MG_Test): make the CTS runner's chunk timeout idle-based so a healthy 20-minute invocation is no longer killed and its in-flight case mis-recorded as a crash, and link MSVC test executables with /WHOLEARCHIVE so the dllimport gl* references in GetProcAddress.cpp resolve 2026-09-05 05:36:50 -04:00
swung0x48 795e08f7e6 [Fix, Test] (ShaderTranspiler): flatten fp64 storage blocks whose last member is a runtime array - the pass declined them, so the fp64 demotion re-derived ArrayStride 4 over the application's 8/16/32-byte double buffer and every double/dvecN data[] SSBO read raw words 2026-09-05 05:36:49 -04:00
swung0x48 1e7ecab4db [Fix, Test] (MG_State, BufferObject): land a non-persistent write map's staged bytes into a GPU-resident store at unmap and explicit flush instead of dropping them - SSBO binding and large-store adoption make resident stores reachable through glMapBufferRange, so every per-draw re-initialisation was silently lost 2026-09-05 05:36:48 -04:00
121 changed files with 22323 additions and 3141 deletions
+81
View File
@@ -295,6 +295,33 @@ jobs:
path: /tmp/core.*
if-no-files-found: ignore
# 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
benchmark:
runs-on: ubuntu-latest
needs: build-linux
@@ -778,3 +805,57 @@ 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.
steps:
- name: Checkout repo
uses: actions/checkout@v6
# 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
# 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"
# Informational: the frontend mutation surface an MGPipe aggregate generation has to
# cover. It becomes a gate in P1, when the mapping file exists to diff against.
- name: MGPipe dirty-surface report
run: python3 scripts/gen_pipe_dirty_surface.py --summary
# 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
+3
View File
@@ -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
Vendored Submodule
+1
Submodule 3rdparty/flatbuffers added at 7e163021e5
+108
View File
@@ -14,6 +14,14 @@ 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)
option(MOBILEGL_BUILD_SERVER_SPIKE "Build the P0 spike-A MobileGLServer delivery-chain executable (Android only)" OFF)
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 +246,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
@@ -417,6 +427,49 @@ 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.
# ---------------------------------------------------------------------------
# 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)
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
)
endif()
if (APPLE AND NOT MOBILEGL_IOS)
list(APPEND SOURCE_FILES
MobileGL/MG_Impl/CGLImpl/CGLImpl.cpp
@@ -467,11 +520,19 @@ set(MOBILEGL_COMPILE_DEF
-DASIO_NO_DEPRECATED
)
if (MOBILEGL_BUILD_DISAGGREGATED)
list(APPEND MOBILEGL_COMPILE_DEF -DMOBILEGL_BUILD_DISAGGREGATED=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 <MG_Pipe/MGPipe.h> through the
# line above too; this entry lets the client, the backends and MG_Remote spell them
# as <MGPipe.h> 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}
@@ -482,6 +543,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}
)
@@ -698,3 +766,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/<abi>/, 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()
+40
View File
@@ -316,6 +316,46 @@ 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 default and the only shipped value until the migration lands - is "pull
// everything", i.e. exactly today's behaviour. One bit of it also turns OFF
// client-side content addressing of CSOs, which is the negative control the CSO
// design is measured against. Accepts decimal or 0x-prefixed hex.
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;
// 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;
} // namespace MobileGL::MG_Config
+47
View File
@@ -159,6 +159,38 @@ namespace MobileGL::MG_ConfigLoader {
return static_cast<Uint32>(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<unsigned long long>(defaultValue));
return defaultValue;
}
return static_cast<Uint64>(parsedValue);
}
inline void InitFeatures() {
auto& features = MG_Config::Features;
features.DisableTimerQuery = QueryEnvFlag("MOBILEGL_DISABLE_TIMERQUERY");
@@ -207,6 +239,21 @@ 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.
features.PipePush = QueryEnvUint64("MOBILEGL_PIPE_PUSH", 0);
features.PipeVerify = QueryEnvFlag("MOBILEGL_PIPE_VERIFY");
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() {
+10
View File
@@ -17,6 +17,7 @@
#include <MG_Impl/GLImpl/Sync/GL_Sync.h>
#include <MG_Impl/GLImpl/Query/GL_Query.h>
#include <MG_Util/Async/ShaderCompilePool.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_State/GLState/ProgramState/ProgramTranslationCache.h>
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
@@ -42,6 +43,11 @@ namespace MobileGL {
if (logLifecycle) {
MGLOG_I("MobileGL closing...");
}
// 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 +108,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();
+29 -2
View File
@@ -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.
@@ -1255,8 +1255,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 +1415,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
+80 -125
View File
@@ -28,6 +28,7 @@
#include <MG_Util/Converters/MGToGL/RenderStateEnumConverter.h>
#include <MG_Util/Math/HalfFloat.h>
#include <MG_Util/Metrics/BufferMetrics.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <MG_Util/Texture/PixelStoreProcessor.h>
#include <Config.h>
#include <atomic>
@@ -1412,10 +1413,21 @@ namespace MobileGL::MG_Backend::DirectGLES {
static Uint64 CurrentUnitBindingsEpoch(Int maxTouchedUnit) {
const Uint64 contextId = MG_State::pGLContext->GetTextureContextId();
const Uint64 bindGeneration = MG_State::pGLContext->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);
@@ -1505,6 +1517,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
keys.maxTouchedUnit = MG_State::pGLContext->GetMaxTouchedTextureUnit();
keys.samplingGeneration = MG_State::pGLContext->GetSamplingResolutionGeneration();
keys.unitBindingsEpoch = CurrentUnitBindingsEpoch(keys.maxTouchedUnit);
if (MG_Util::PipeStats::Enabled()) {
// The three reads above; CurrentUnitBindingsEpoch counts its own two.
MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 3);
}
return keys;
}
@@ -1534,6 +1550,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,6 +1567,13 @@ 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<Uint64>(maxTouchedUnit) + 1u : 0u);
}
g_unitTextureSyncListValid = false;
g_unitTextureSyncList.clear();
for (Int index = 0; index <= maxTouchedUnit; ++index) {
@@ -2006,8 +2034,22 @@ 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();
@@ -2924,6 +2966,14 @@ namespace MobileGL::MG_Backend::DirectGLES {
// 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();
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);
MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::AccessorCalls, 2);
}
const TextureImpl::DrawTextureSyncKeys textureKeys = TextureImpl::CaptureDrawTextureSyncKeys();
BufferImpl::SyncNeccessaryBuffers(currentVAO, vaoTwin, vaoConfigVersion,
@@ -3370,6 +3420,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (BufferImpl::UboRingAllocate(bindSize, offset)) {
std::memcpy(static_cast<Uint8*>(BufferImpl::UboRingMappedPtr()) + offset,
currentProgram->MapUBO(), uboSize);
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageUboGlobal,
static_cast<Uint64>(uboSize));
}
ringSlot = {uboContentVersion, BufferImpl::UboRingGeneration(), frameSerial,
offset};
slotValid = true;
@@ -3389,6 +3443,11 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, backendProgram.GetBackendGlobalUBOId());
g_GLESFuncs.glBufferSubData(GL_UNIFORM_BUFFER, 0, currentProgram->GetUBOSize(),
currentProgram->MapUBO());
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(
MG_Util::PipeStats::ByteClass::StageUboGlobal,
static_cast<Uint64>(currentProgram->GetUBOSize()));
}
g_GLESFuncs.glBindBuffer(GL_UNIFORM_BUFFER, 0);
backendProgram.SetLastUploadedGlobalUboVersion(uboContentVersion);
}
@@ -4306,6 +4365,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_restartIndices.capacity = capacity;
if (data != nullptr && bytes != 0) {
g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast<GLsizeiptr>(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<Uint64>(bytes));
}
}
return true;
}
@@ -7252,138 +7317,22 @@ namespace MobileGL::MG_Backend::DirectGLES {
TextureImpl::SyncImageTextureBinding(unit);
}
// 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_BINDING: {
auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);
auto& obj = point.GetBoundObject();
*data = obj ? static_cast<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_SHADER_STORAGE_BUFFER_START: {
auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);
*data = static_cast<GLint>(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<GLint>(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<Int>(index));
*data = imageBinding.Texture ? static_cast<GLint>(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<Int>(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<Int>(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<Int>(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<Int>(index));
*data = static_cast<GLint>(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<Int>(index));
*data = static_cast<GLint>(imageBinding.Format);
return;
}
default:
if (g_GLESFuncs.glGetIntegeri_v) {
g_GLESFuncs.glGetIntegeri_v(target, index, data);
} else {
*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<GLint64>(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<GLint64>(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;
}
g_GLESFuncs.glGetProgramiv(backendProgramId, pname, params);
}
// NOTE the shape here, and do not "simplify" it back to GetBackendProgramId(): this entry
@@ -10638,6 +10587,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() {
@@ -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);
@@ -15,6 +15,7 @@
#include <MG_Util/ShaderTranspiler/TranslationCache.h>
#include <MG_Util/BackendLoaders/OpenGL/Loader.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <MG_Util/Converters/GLToStr/GLEnumConverter.h>
#include <MG_Util/Converters/MGToGL/DataTypeConverter.h>
#include <MG_Util/Converters/MGToGL/BufferEnumConverter.h>
@@ -779,6 +780,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
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<Uint64>(size));
}
resource.storageSize = size;
resource.storageInitialized = true;
resource.pendingRespecify = false;
@@ -886,6 +893,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<Uint64>(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
@@ -953,6 +967,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<Uint64>(size));
}
SizeT ringOffset = 0;
if (ringUsable && size <= kUploadRingMaxBytes &&
RingAllocate(g_uploadRing, size, ringOffset)) {
@@ -1520,6 +1538,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<Uint64>(poolSize));
}
{
const std::lock_guard<std::mutex> lock(resource->pendingMutex);
resource->pendingRanges.clear();
@@ -2545,6 +2570,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER,
static_cast<GLsizeiptr>(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<Uint64>(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,
@@ -2575,6 +2604,10 @@ namespace MobileGL::MG_Backend::DirectGLES {
BufferImpl::BindBufferId(GL_ARRAY_BUFFER, bufferId);
g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(uploadSize), clientData,
GL_STREAM_DRAW);
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageVertexClient,
static_cast<Uint64>(uploadSize));
}
if (!attrib.IsInteger) {
const GLint glSize = attrib.IsBgra ? static_cast<GLint>(GL_BGRA) : attrib.Size;
@@ -2669,6 +2702,12 @@ namespace MobileGL::MG_Backend::DirectGLES {
g_GLESFuncs.glBufferData(GL_ARRAY_BUFFER,
static_cast<GLsizeiptr>(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<Uint64>(converted.size() * sizeof(Float)));
}
stream.valid = true;
stream.sourceLifetimeId = sourceLifetimeId;
stream.sourceChangeSerial = sourceChangeSerial;
@@ -4391,6 +4430,42 @@ namespace MobileGL::MG_Backend::DirectGLES {
if (ringStaged) {
BufferImpl::BindPixelUnpackBufferId(BufferImpl::UnpackRingBufferId());
}
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<Uint64>(rect.hi.x() - rect.lo.x()) *
static_cast<Uint64>(rect.hi.y() - rect.lo.y()) *
static_cast<Uint64>(std::max(rect.hi.z() - rect.lo.z(), 1)) *
static_cast<Uint64>(bpp);
}
} else if (subRectEligible) {
shippedBytes = static_cast<Uint64>(regionSize.x()) *
static_cast<Uint64>(regionSize.y()) *
static_cast<Uint64>(std::max(regionSize.z(), 1)) *
static_cast<Uint64>(bpp);
} else {
shippedBytes = static_cast<Uint64>(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<Uint64>(dirtyRectCount) : 1u);
}
switch (MapToBackendTextureTarget(stateTextureObject->GetTarget())) {
case TextureTarget::Texture2D:
case TextureTarget::TextureCubeMap:
+25 -6
View File
@@ -9,6 +9,7 @@
#include "MultiDraw.h"
#include "Managers.h"
#include <MG_State/GLState/Core.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <cstring>
#include <limits>
@@ -156,7 +157,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 +173,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
buffer.cursor = 0;
if (data) {
g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, 0, static_cast<GLsizeiptr>(bytes), data);
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(statsClass, static_cast<Uint64>(bytes));
}
}
return true;
}
@@ -183,7 +190,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 +215,9 @@ namespace MobileGL::MG_Backend::DirectGLES::MultiDrawImpl {
if (data) {
g_GLESFuncs.glBufferSubData(BufferImpl::TempBufferTarget, static_cast<GLintptr>(outOffset),
static_cast<GLsizeiptr>(bytes), data);
if (MG_Util::PipeStats::Enabled()) {
MG_Util::PipeStats::AddBytes(statsClass, static_cast<Uint64>(bytes));
}
}
buffer.cursor += aligned;
return true;
@@ -417,7 +428,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;
}
@@ -532,7 +544,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;
}
@@ -737,10 +750,16 @@ 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, 1, g_drawInfo.id);
@@ -740,8 +740,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;
@@ -939,6 +937,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);
+22 -115
View File
@@ -13,6 +13,7 @@
#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"
#include <atomic>
@@ -77,7 +78,6 @@ namespace MobileGL::MG_Backend::DirectVulkan {
Uint32 blockBindingVersion = 0;
Vector<StorageBlockResource> storageBlocks;
Vector<BufferVariableResource> bufferVariables;
GLint computeWorkGroupSize[3] = {1, 1, 1};
};
struct DrawElementsIndirectCommand {
@@ -208,16 +208,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<GLint>(std::max<Uint32>(entryPoint.local_size.x, 1));
cache.computeWorkGroupSize[1] = static_cast<GLint>(std::max<Uint32>(entryPoint.local_size.y, 1));
cache.computeWorkGroupSize[2] = static_cast<GLint>(std::max<Uint32>(entryPoint.local_size.z, 1));
}
uint32_t bindingCount = 0;
SpvReflectResult result = spvReflectEnumerateDescriptorBindings(&module, &bindingCount, nullptr);
if (result != SPV_REFLECT_RESULT_SUCCESS || bindingCount == 0) {
@@ -682,130 +672,40 @@ 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");
switch (target) {
case GL_MAX_COMPUTE_WORK_GROUP_COUNT:
if (index >= 3) {
*data = 0;
return;
}
switch (target) {
case GL_MAX_COMPUTE_WORK_GROUP_COUNT:
*data = static_cast<GLint>(
pVulkanRenderer->GetPhysicalDevice().properties.limits.maxComputeWorkGroupCount[index]);
return;
case GL_MAX_COMPUTE_WORK_GROUP_SIZE:
if (index >= 3) {
*data = 0;
return;
}
*data = static_cast<GLint>(
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<GLint>(obj->GetExternalIndex()) : 0;
return;
}
case GL_SHADER_STORAGE_BUFFER_START: {
auto& point = MG_State::pGLContext->GetBufferBindingPoint(BufferTarget::ShaderStorage, index);
*data = static_cast<GLint>(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<GLint>(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<Int>(index));
if (target == GL_IMAGE_BINDING_NAME) {
*data = imageBinding.Texture ? static_cast<GLint>(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<GLint>(imageBinding.Access);
} else {
*data = static_cast<GLint>(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<GLint64>(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<GLint64>(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;
@@ -1430,5 +1330,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
@@ -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);
@@ -20,6 +20,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 <Config.h>
@@ -2076,6 +2077,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<Uint64>(outSize));
}
return true;
}
@@ -2283,6 +2294,13 @@ namespace MobileGL::MG_Backend::DirectVulkan {
outBuffer = slice.buffer;
outRange = ubo.payloadSize;
outDynamicOffset = static_cast<Uint32>(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<Uint64>(ubo.payloadSize));
}
if (isGlobalUbo) {
m_globalUboMemo[m_globalUboMemoNext] =
GlobalUboSliceMemo{uboProgramLifetimeId, uboFrameSerial, uboContentVersion,
@@ -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 =
@@ -229,8 +231,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<Uint64>(size));
break;
case BufferKind::Index:
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndexClient,
static_cast<Uint64>(size));
break;
case BufferKind::Indirect:
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageIndirectCmd,
static_cast<Uint64>(size));
break;
case BufferKind::TextureBuffer:
case BufferKind::ShaderStorage:
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageBuffer,
static_cast<Uint64>(size));
break;
case BufferKind::Uniform:
break;
}
}
return true;
}
Bool VkBufferManager::InitializeTransientArenas() {
@@ -339,6 +371,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<Uint64>(size));
}
resource.pendingFullUpload = false;
return true;
}
@@ -353,6 +388,11 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static_cast<VkDeviceSize>(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<Uint64>(size));
}
VkCommandBuffer commandBuffer = m_copyProvider->AcquireBufferCopyCommandBuffer();
if (commandBuffer == VK_NULL_HANDLE) {
return false;
@@ -422,6 +462,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<Uint64>(size));
}
}
@@ -447,6 +489,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static_cast<VkDeviceSize>(size), static_cast<VkDeviceSize>(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<Uint64>(size));
}
return;
}
@@ -484,6 +529,9 @@ namespace MobileGL::MG_Backend::DirectVulkan {
static_cast<VkDeviceSize>(size), static_cast<VkDeviceSize>(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<Uint64>(size));
}
return;
}
@@ -554,6 +602,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<Uint64>(size));
}
}
resource->persistentMapped = true;
resource->pendingFullUpload = false;
@@ -602,6 +657,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<Uint64>(size));
}
resource->pendingFullUpload = false;
}
@@ -681,6 +740,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<Uint64>(size));
}
resource->transientSlice = outSlice;
resource->transientFrameSerial = m_frameSerial;
resource->transientChangeSerial = changeSerial;
@@ -13,6 +13,7 @@
#include "MG_State/GLState/Core.h"
#include "MG_Util/Converters/MGToStr/TextureEnumConverter.h"
#include "MG_Util/Converters/MGToVk/TextureEnumConverter.h"
#include "MG_Util/Metrics/PipeStats.h"
#include <Config.h>
#include <algorithm>
@@ -3150,6 +3151,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<Uint64>(item.rects.size());
}
}
MG_Util::PipeStats::AddBytes(MG_Util::PipeStats::ByteClass::StageTexture,
static_cast<Uint64>(stagingSize));
MG_Util::PipeStats::AddCalls(MG_Util::PipeStats::CallClass::TextureUploadEmissions,
static_cast<Uint64>(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;
@@ -27,6 +27,7 @@
#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"
@@ -3266,8 +3267,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) {
@@ -4999,9 +5001,19 @@ void main() {
entry.pipelineStateHash == pipelineStateHash &&
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
@@ -5242,6 +5254,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,
@@ -5890,8 +5920,18 @@ void main() {
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
@@ -6365,6 +6405,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;
}
@@ -6390,9 +6439,27 @@ 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();
if (drawFbo != nullptr && IsUnsupportedFramebufferForDirectVulkan(*drawFbo)) {
@@ -11133,16 +11200,66 @@ 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<Uint32>(kXfbCounterObjectSlots);
const Uint64 identity = MG_State::pGLContext->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;
}
// 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 (m_xfbCounterSlotOwner[slot] == 0 && freeSlot == kNoSlot) {
freeSlot = slot;
}
}
Uint32 slot = freeSlot;
if (slot == kNoSlot) {
for (Uint32 candidate = 0; candidate < kNoSlot; ++candidate) {
if (MG_State::pGLContext->HasOpenTransformFeedbackSpan(m_xfbCounterSlotOwner[candidate])) {
continue;
}
if (slot == kNoSlot || m_xfbCounterSlotLastUse[candidate] < m_xfbCounterSlotLastUse[slot]) {
slot = candidate;
}
}
}
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;
}
@@ -675,8 +675,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<Uint, Uint32> 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<Uint64, kXfbCounterObjectSlots> 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<Uint64, kXfbCounterObjectSlots> 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<Bool, kXfbCounterObjectSlots> m_xfbCountersValid{};
@@ -51,6 +51,7 @@ endif()
add_executable(MobileGLIntegrationTest
Main.cpp
Harness/HeadlessGL.cpp
Harness/BackendCapsPeek.cpp
Scenarios/OrientationScenario.cpp
Scenarios/CrossFrameBufferScenario.cpp
Scenarios/ResidentIndexScenario.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 <MG_Backend/BackendObject.h>
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<BackendObject>& 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
@@ -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
@@ -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 <algorithm>
#include <string>
#include <vector>
#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<GLint>(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<GLint64>(buffer));
EXPECT_EQ(start64, static_cast<GLint64>(256));
EXPECT_EQ(size64, static_cast<GLint64>(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<GLint64>(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<GLint>(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<GLint64>(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
+106
View File
@@ -0,0 +1,106 @@
// MobileGL - MobileGL/MG_Pipe/Coverage.def
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The hand-maintained half of G6 (plan B section 4.7, gate 10.3-5): which MGPipe call
// answers each backend read point in scripts/data/backend_read_inventory.md (477 rows, 57
// files, generated from the backends by MobileGL-CS's extract_backend_read_inventory.py).
//
// gen_pipe.py joins the inventory's `member` column against MGP_COVERAGE_ACCESSOR_LIST and
// its `delta` column against MGP_COVERAGE_DELTA_LIST, then writes generated/PipeCoverage.inc
// with the per-accessor table and prints the coverage summary. Rows matching neither are
// UNMAPPED: allowed in P0 and merely counted, ZERO from P5 onward, when the gate becomes
// "regenerate and git diff --exit-code with 0 UNMAPPED".
//
// Three pseudo-calls stand for read points that do NOT become a forward call:
// kClientResolved - the frontend answers it itself; the server is never asked
// (section 4.4.6: "the server answers nothing the client can answer").
// kReverseChannel - it becomes one of the ten MGPipeCallbacks (section 7.1).
// kStructuralHandle - the row is a SIGNATURE carrying SharedPtr<MG_State...>, which
// becomes an MGPipeHandle parameter; there is no single call to name.
//
// clang-format off
// X(Accessor, PipeCall)
#define MGP_COVERAGE_ACCESSOR_LIST(X) \
X(GetActiveTextureUnit, SetSamplerViews) \
X(GetBlendColor, SetDynamicState) \
X(GetBlendEquationIndexed, CreateRenderState) \
X(GetBlendFuncIndexed, CreateRenderState) \
X(GetBoundTransformFeedbackName, SetStreamOutputTargets) \
X(GetBoundVertexArray, BindVertexElements) \
/* Polymorphic over BufferTarget: its rows split across set_vertex_buffers, */ \
/* set_index_buffer, set_indirect_buffers and set_shader_buffers once the */ \
/* inventory carries the target argument (P1). Named for the plan's explicit */ \
/* replacement of the DrawIndirect/Parameter pair. */ \
X(GetBufferBindingSlot, SetIndirectBuffers) \
X(GetBufferBindingPoint, SetShaderBuffers) \
X(GetBufferBindingPointCount, SetShaderBuffers) \
X(GetTouchedBufferBindingPointCount, SetShaderBuffers) \
X(GetClampReadColor, SetDynamicState) \
X(GetClearColor, SetDynamicState) \
X(GetClearDepth, SetDynamicState) \
X(GetClearStencil, SetDynamicState) \
X(GetColorMaskIndexed, CreateRenderState) \
X(GetCullFaceMode, CreateRenderState) \
X(GetCurrentVertexAttribute, SetVertexAttribDefaults) \
X(GetDepthFunc, CreateRenderState) \
X(GetDepthMask, CreateRenderState) \
X(GetDepthRangeIndexed, SetDynamicState) \
X(GetFramebufferBindingSlot, SetFramebufferState) \
X(GetImageTextureBinding, SetShaderImages) \
X(GetLineWidth, SetDynamicState) \
X(GetLogicOp, CreateRenderState) \
X(GetMaxTouchedTextureUnit, SetSamplerViews) \
X(GetMinSampleShadingValue, CreateRenderState) \
X(GetPatchDefaultInnerLevel, SetPatchState) \
X(GetPatchDefaultOuterLevel, SetPatchState) \
X(GetPatchVertices, SetPatchState) \
X(GetPipelineStateVersion, BindRenderState) \
X(GetPixelStoreParameters, SetPixelPackState) \
X(GetPolygonModeFront, CreateRenderState) \
X(GetPolygonOffsetFactor, SetDynamicState) \
X(GetPolygonOffsetUnits, SetDynamicState) \
X(GetPrimitiveRestartIndex, DrawVbo) \
X(GetProgramForDispatch, SetDispatchProgram) \
X(GetProgramForDraw, SetDrawProgram) \
X(GetProgramObject, CreateShaderState) \
/* Not in ComputePipelineStateHash today even though Vulkan makes it pipeline */ \
/* state; recorded here so the G7 chunk table has to answer for it before it */ \
/* freezes (section 10.3-5). */ \
X(GetProvokingVertexMode, CreateRenderState) \
X(GetRenderStateParameters, CreateRenderState) \
X(GetRenderStateParametersVersion, BindRenderState) \
X(GetSamplingResolutionGeneration, SetSamplerViews) \
X(GetScissorBox, SetDynamicState) \
X(GetStencilState, CreateRenderState) \
X(GetTextureBindGeneration, SetSamplerViews) \
X(GetTextureContextId, SetSamplerViews) \
X(GetTextureObject, SetSamplerViews) \
X(GetTextureUnitObject, SetSamplerViews) \
X(GetTransformFeedbackCapturedVertices, DrawVbo) \
X(GetTransformFeedbackGeneration, SetStreamOutputTargets) \
X(GetTransformFeedbackPausedPrimitiveCounter, EndStreamOutput) \
X(GetTransformFeedbackProgram, SetStreamOutputTargets) \
X(GetViewport, SetDynamicState) \
X(GetViewportIndexed, SetDynamicState) \
X(IsCapabilityEnabled, CreateRenderState) \
X(IsCapabilityEnabledIndexed, CreateRenderState) \
X(IsTransformFeedbackActive, BeginStreamOutput) \
X(IsTransformFeedbackPaused, PauseStreamOutput) \
X(InvalidateCompileEnv, kClientResolved) \
X(ValidateProgramName, kClientResolved) \
X(RecordError, kReverseChannel)
// X(DeltaKind, PipeCall) - for inventory rows with no accessor in the member column.
// Read by gen_pipe.py ONLY, never by the C++ preprocessor: the delta kinds are the
// inventory's own free-text labels, not C tokens.
#define MGP_COVERAGE_DELTA_LIST(X) \
X(handle-ify (wire handle), kStructuralHandle) \
X(Buffer ops delta, ResourceRespecify)
// clang-format on
+93
View File
@@ -0,0 +1,93 @@
// MobileGL - MobileGL/MG_Pipe/MGPipe.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include "MGPipeCallbacks.h"
#include "MGPipeHandles.h"
#include "MGPipeHostSpan.h"
#include "MGPipeTypes.h"
// The MGPipe boundary (plan B section 4).
//
// The two interface tables are FUNCTION-POINTER STRUCTS, not virtual bases. Three reasons
// out of this repository rather than out of gallium: the boundary already is a
// function-pointer struct sitting on one hook point in MG_Backend/Init.cpp; a nullptr entry
// already means "not implemented, frontend falls back", which is exactly what a
// not-yet-migrated subsystem needs to say while it keeps pulling; and MG_Test already
// substitutes this table to mock a backend. The rare EGL and caps surface stays on
// pActiveBackendObject's virtual functions.
namespace MobileGL::MG_Pipe {
// Unscoped on purpose: PipeCalls.def spells these as bare tokens so the same file can
// be read by the C++ preprocessor and by scripts/gen_pipe.py.
enum MGPipeCallClass : Uint8 {
kScreen,
kCtxCso,
kCtxState,
kCtxObject,
kCtxVerb,
kCtxQuery,
kCallClassCount,
};
enum MGPipeCallFlags : Uint32 {
kNone = 0,
// The caller must not proceed until the server has acknowledged. Rare by design.
kNeedsAck = 1u << 0,
// Carries an MGPBlobRef.
kHasBlob = 1u << 1,
// Carries a variable-length array after the fixed payload.
kVarTail = 1u << 2,
// Carries an MGHostSpan - the one shape that changes with the transport.
kHostSpan = 1u << 3,
// Answers into an MGPReplySlot; never blocks.
kReplySlot = 1u << 4,
// May be null in a backend's table. A null entry is a real answer ("this backend
// does not implement it"), not an error: DirectVulkan deliberately leaves
// buffer_subdata_resident unregistered, and SetSwapInterval likewise.
kOptional = 1u << 5,
};
// The pipeline/dynamic split of RenderStateParameters, defined exactly once (section
// 4.5.2). Generated by G7 from the field list ComputePipelineStateHash already hashes;
// MGPipeRenderStateSpans.cpp and the setter-consistency test land with P2, which is
// when the chunk table can be filled with real offsets.
struct MGPipeRenderStateSpans;
// The catalogue itself. Only macros, so it is safe to expand inside the namespace, and
// consumers (the unit test, later the transport) get MGP_CALL_LIST from this header.
#include "PipeCalls.def"
// G1: the two interface tables. A null entry means "not implemented" (section 4.1).
#include "generated/PipeTables.inc"
// The installed tables. Zero-initialized, so an un-installed MGPipe is every entry
// null - which is precisely the pre-migration state.
inline MGPipeScreen gMGPipeScreen{};
inline MGPipeContext gMGPipeContext{};
// G2: monolith thunks. These are what MG_Impl call sites move onto, replacing
// gBackendFunctionsTable.GL.* one name at a time.
#include "generated/PipeThunks.inc"
// G3: wire records, their size assertions, and the applier's bounds precondition.
#include "generated/PipeWire.inc"
// G4: the MOBILEGL_PIPE_VERIFY field-wise comparators.
#include "generated/PipeVerify.inc"
// G5: PipeInputs field ids and the per-verb poison generations.
#include "generated/PipeFilled.inc"
// G6: the backend read inventory's coverage table.
#include "generated/PipeCoverage.inc"
// G7: the render-state pipeline subset, by member name.
#include "generated/PipeSpanTable.inc"
} // namespace MobileGL::MG_Pipe
+61
View File
@@ -0,0 +1,61 @@
// MobileGL - MobileGL/MG_Pipe/MGPipeCallbacks.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include "MGPipeHandles.h"
#include "MGPipeTypes.h"
// The backend -> frontend reverse channel, named (plan B section 7.1).
//
// Today this traffic is 95 call sites across 17 methods poked directly into frontend
// objects. gallium has no vocabulary for shadow writeback, GPU-write notification, texture
// re-send requests or default-framebuffer geometry, because in Mesa the state tracker and
// the driver share an address space. Naming them as ten callbacks plus one forward
// terminator (MGPipeContext::ResourceSubDataComplete) is the deliberate deviation (D8).
//
// Installed at context creation. In a monolith these are direct calls; under split they are
// records on the reverse channel, and their ORDER is a correctness requirement rather than
// an optimization (section 7.4).
namespace MobileGL::MG_Pipe {
struct MGPipeCallbacks {
// A driver-detected GL error that only the server could have seen.
void (*OnGlError)(Uint32 code);
// Ranges of a resource the GPU wrote; retires MarkGpuWritten.
void (*OnGpuWritten)(MGPipeHandle res, Uint rangeCount, const MGPRange* ranges);
void (*OnBufferWriteback)(MGPipeHandle res, Uint64 offset, MGPBlobRef bytes);
void (*OnTextureWriteback)(MGPipeHandle res, const MGPBox* box, MGPBlobRef bytes);
// The one new stall class in this design (D-B6): the server recast a texture and
// needs its texels back. The client answers with zero or more ResourceSubData
// records terminated by ResourceSubDataComplete carrying the same pullSerial.
void (*OnTexturePullRequest)(MGPipeHandle res, Uint16 target, Uint16 firstLevel, Uint16 levelCount,
Uint64 pullSerial);
// SHAPE ONLY, never bytes: the client owns the CPU shadow and allocates the levels
// itself.
void (*OnMipLevelsGenerated)(MGPipeHandle res, Uint16 base, Uint16 count);
// Retires the layering inversion where the swapchain writes into MG_Impl's
// pDefaultFramebufferInfo.
void (*OnSurfaceChanged)(const MGPSurfaceInfo* info);
void (*OnCapsInvalidated)();
// <= WARN is lossy, >= ERROR is lossless and rate limited.
void (*OnLog)(Uint8 level, const char* text);
// The XFB scatter is a read-modify-write of the CLIENT's shadow, so the server
// hands back the packed scratch and the client scatters (section 7.2.1).
void (*OnXfbScatterReady)(MGPipeHandle scratch, Uint64 packedStride, Uint64 vertices);
};
// Ten, and the count is asserted so an eleventh cannot be added without touching the
// transport's reverse-channel record table.
inline constexpr SizeT kMGPipeCallbackCount = 10;
static_assert(sizeof(MGPipeCallbacks) == kMGPipeCallbackCount * sizeof(void (*)()),
"MGPipeCallbacks gained or lost a callback");
// Null-initialized: a backend that installs nothing sends nothing.
inline MGPipeCallbacks gMGPipeCallbacks{};
} // namespace MobileGL::MG_Pipe
+98
View File
@@ -0,0 +1,98 @@
// MobileGL - MobileGL/MG_Pipe/MGPipeHandles.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// MGPipe object identity (plan B section 4.2).
//
// A handle is a {slot, gen} pair minted by the CLIENT and never by the server: no create_*
// call in the catalogue returns a server-cast handle, which is the deliberate deviation
// from gallium (D1) that lets the whole catalogue be remoted with ZERO creation round
// trips.
//
// Slots are dense and allocated PER KIND, so the server's object table is an array rather
// than a hash map. The allocator is a free list plus a high-water mark and has nothing to
// do with MG_State's IndexGenerator - that container's LIFO name reuse is the very problem
// {slot, gen} exists to close.
namespace MobileGL::MG_Pipe {
enum class MGPipeKind : Uint8 {
None = 0,
Buffer = 1,
Texture,
Renderbuffer,
Framebuffer,
Xfb,
RenderStateCso,
VertexElementsCso,
SamplerCso,
SamplerViewCso,
ShaderCso,
Fence,
Query,
Context,
KindCount,
};
// 8 bytes, POD, passed by value in a register pair.
//
// Gen increments only when a SLOT IS REUSED - never on a respecify - so {slot, gen} is
// unique until the same slot has been recycled 2^32 times. That bound is documented
// rather than defended at runtime in release builds: at one recycle per frame at
// 1000 fps a single slot would take ~50 days of continuous churn to wrap, and the
// debug allocator asserts on the wrap.
//
// Two generations exist in this design and they are strictly separate (section 4.2.2):
// this one is the CLIENT's answer to "is this still the same GL object", while MGGen is
// the SERVER's own epoch for "did I recast my driver object". Interface rule: no MGPipe
// call may require the client to supply or know MGGen.
struct MGPipeHandle {
Uint32 Slot;
Uint32 Gen;
friend constexpr Bool operator==(const MGPipeHandle& a, const MGPipeHandle& b) {
return a.Slot == b.Slot && a.Gen == b.Gen;
}
};
static_assert(sizeof(MGPipeHandle) == 8, "MGPipeHandle is the 8-byte {slot, gen} pair");
static_assert(alignof(MGPipeHandle) == 4, "MGPipeHandle must not gain padding on the wire");
static_assert(std::is_trivially_copyable_v<MGPipeHandle>);
// Reserved handles (section 4.2.1).
// {0, 0} is null for every kind.
// {0, 1} of kind Framebuffer is the DEFAULT framebuffer. It exists so the four
// pDefaultFramebufferInfo->defaultFBO identity comparisons in DirectGLES retire into
// an ordinary handle compare.
inline constexpr MGPipeHandle kMGPipeNullHandle{0, 0};
inline constexpr MGPipeHandle kMGPipeDefaultFramebuffer{0, 1};
inline constexpr Bool MGPipeHandleIsNull(const MGPipeHandle& handle) {
return handle.Slot == 0 && handle.Gen == 0;
}
// Slot 0 of every kind is reserved (null, and the default framebuffer for kind
// Framebuffer), so a real allocation starts at 1.
inline constexpr Uint32 kMGPipeFirstAllocatableSlot = 1;
// ShaderCso slot space. The top 1/16 of it is reserved for PROGRAM PIPELINE COMPOSITES
// (section 5.6.3): a composite is minted client-side out of the stage programs bound to
// a pipeline object, and the server never learns it is a composite - it is just another
// ShaderCso. Reserving a band rather than a flag keeps the composite resolver's
// lifetime bookkeeping out of the ordinary program slot allocator.
inline constexpr Uint32 kMGPipeShaderCsoSlotLimit = 1u << 20;
inline constexpr Uint32 kMGPipeShaderCsoCompositeSlotBase =
kMGPipeShaderCsoSlotLimit - (kMGPipeShaderCsoSlotLimit >> 4);
inline constexpr Bool MGPipeIsCompositeShaderSlot(Uint32 slot) {
return slot >= kMGPipeShaderCsoCompositeSlotBase && slot < kMGPipeShaderCsoSlotLimit;
}
static_assert(kMGPipeShaderCsoCompositeSlotBase > kMGPipeFirstAllocatableSlot,
"the composite band must not swallow the ordinary program slots");
} // namespace MobileGL::MG_Pipe
+57
View File
@@ -0,0 +1,57 @@
// MobileGL - MobileGL/MG_Pipe/MGPipeHostSpan.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// The ONE thing in MGPipe whose shape changes with the transport (plan B section 4.5.7).
//
// Monolith: Ptr addresses the frontend shadow or the application's own memory and the
// accessor is one predictable branch. Split: Ptr is null and the bytes live in a staging
// segment named by Seg/Offset, or - for the index bytes a server-side primitive-restart
// rewrite or multi-draw flattening consumes - in the server's own index host mirror, which
// costs no wire traffic at all (D-B7).
namespace MobileGL::MG_Pipe {
// Seg sentinels. Anything else is a real SEG_STAGE id assigned by the transport.
inline constexpr Uint32 kMGHostSpanSegNone = 0;
// "The bytes are already on your side": the server reads them out of the index host
// mirror it maintains for every resource created with the ELEMENT_ARRAY bind bit while
// kCapNeedsHostIndexBytes is set. When the mirror is over budget the tracker degrades
// to per-draw staging and counts the bytes in index-bytes-shipped.
inline constexpr Uint32 kMGHostSpanSegFromServerIndexMirror = 0xFFFFFFFFu;
struct MGHostSpan {
// Field order is chosen so the struct is 32 bytes with natural alignment on both a
// 64-bit and a 32-bit host: the pointer and the two 32-bit words fill the first
// 16-byte block either way.
const void* Ptr;
Uint32 Seg;
Uint32 Pad0;
Uint64 Size;
Uint64 Offset;
};
static_assert(sizeof(MGHostSpan) == 32, "MGHostSpan is the 32-byte host-bytes descriptor");
static_assert(std::is_trivially_copyable_v<MGHostSpan>);
// Split-mode resolution needs the transport's segment table, which does not exist in a
// monolith build; the hook is a weak-ish indirection installed by MG_Remote when it is
// compiled in. In P0 there is no transport, so a span that names a segment resolves to
// null and every caller is still on the monolith branch.
using MGPipeSegmentResolver = const void* (*)(Uint32 seg, Uint64 offset, Uint64 size);
inline MGPipeSegmentResolver gMGPipeSegmentResolver = nullptr;
// One predictable branch on the hot path.
inline const void* MGPipeHostBytes(const MGHostSpan& span) {
if (span.Ptr != nullptr) {
return static_cast<const Uint8*>(span.Ptr) + span.Offset;
}
if (gMGPipeSegmentResolver == nullptr) return nullptr;
return gMGPipeSegmentResolver(span.Seg, span.Offset, span.Size);
}
} // namespace MobileGL::MG_Pipe
+806
View File
@@ -0,0 +1,806 @@
// MobileGL - MobileGL/MG_Pipe/MGPipeTypes.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
#include "MGPipeHandles.h"
#include "MGPipeHostSpan.h"
// Every MGPipe payload (plan B section 4.5). Each one is a flat POD with explicit padding,
// carries a static_assert on trivial copyability and one on its exact size, and never
// contains a pointer: MGHostSpan, the one shape that changes with the transport, only ever
// rides in a variable tail (draw_vbo's user indices, set_shader_buffers' named-UBO bytes),
// never inline in a fixed payload.
//
// Sizes are asserted rather than merely documented because the wire records generated from
// these structs (generated/PipeWire.inc) are memcpy'd; a field silently changing width is a
// protocol break that no test would otherwise see.
//
// P0.5 DEBT, recorded here so it is impossible to miss: two payloads reach into headers
// this directory is eventually forbidden to see - MGPCaps embeds MG_Backend's
// DynamicBackendParameters, and ResidualValueBlock embeds MG_State's RenderStateParameters
// and PixelStoreParameters. Both are deliberate: the caps block IS that struct (section
// 4.4.1) and the residual block is the migration carrier for Track V (section 6.3). P0.5
// extracts MGPipeValueTypes.h and both includes below go away; until then purity gate A
// (section 10.3) cannot be armed for this header.
#include <MG_Backend/BackendObject.h>
#include <MG_State/GLState/RenderState/RenderState.h>
namespace MobileGL::MG_Pipe {
using MG_Backend::DynamicBackendParameters;
// Both live directly in namespace MobileGL today; P0.5 moves them into
// MG_Pipe/MGPipeValueTypes.h.
using MobileGL::PixelStoreParameters;
using MobileGL::RenderStateParameters;
// A payload must be memcpy-able and its size must be an exact, stated number.
#define MGP_ASSERT_POD(T, Size) \
static_assert(std::is_trivially_copyable_v<T>, #T " must be trivially copyable"); \
static_assert(sizeof(T) == (Size), #T " changed size; update the wire format and this assertion")
// ---------------------------------------------------------------------------------
// Shared primitives
// ---------------------------------------------------------------------------------
// A run of bytes in the command stream's blob area. Monolith: Seg is
// kMGHostSpanSegNone and Offset is an address into the caller's staging arena. Split:
// Seg names a transport segment.
struct MGPBlobRef {
Uint64 Offset;
Uint64 Size;
Uint32 Seg;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPBlobRef, 24);
struct MGPRange {
Uint64 Offset;
Uint64 Size;
};
MGP_ASSERT_POD(MGPRange, 16);
// Destination box in the level's own coordinate system (section 4.5.6).
struct MGPBox {
Int32 X, Y, Z;
Uint32 W, H, D;
};
MGP_ASSERT_POD(MGPBox, 24);
// Where an asynchronous answer lands. Every server query in this catalogue is
// async-with-handle; none of them blocks (section 4.4.6, "the total rule").
struct MGPReplySlot {
Uint64 Id;
};
MGP_ASSERT_POD(MGPReplySlot, 8);
// One contiguous run of RenderStateParameters bytes. The pipeline/dynamic split is
// defined exactly once, in MGPipeRenderStateSpans, and generated by G7 from the field
// list VulkanRenderer::ComputePipelineStateHash already hashes (section 4.5.2).
struct MGPStateChunk {
Uint16 Offset;
Uint16 Length;
};
MGP_ASSERT_POD(MGPStateChunk, 4);
// The payload of every call that carries nothing but an object identity.
struct MGPHandleOnly {
MGPipeHandle Handle;
Uint32 Kind; // MGPipeKind, widened for a stable wire size
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPHandleOnly, 16);
// ---------------------------------------------------------------------------------
// Screen: caps, resources, fences
// ---------------------------------------------------------------------------------
// Capability bits that replace "is this table slot null" as an implicit feature probe
// (section 4.4.1). The five ownership-switch bits of v1 are deliberately absent: what
// they tried to express - who performs primitive-restart rewriting and multi-draw
// flattening - is not expressible as a capability (D-B7).
enum MGPCapBit : Uint64 {
kCapNone = 0,
kCapViewportArray = 1ull << 0,
kCapFloat64VertexAttrib = 1ull << 1,
kCapResidentSubData = 1ull << 2,
kCapCpuXfbPrimitiveAccounting = 1ull << 3,
kCapTimerQuery = 1ull << 4,
kCapOcclusionQuery = 1ull << 5,
kCapXfbPrimitivesQuery = 1ull << 6,
// The server rewrites restart indices / flattens multi-draws itself and therefore
// needs the index bytes on its side: under split this arms the index host mirror
// (D-B7).
kCapNeedsHostIndexBytes = 1ull << 7,
// The server packs named uniform blocks into its own ring and therefore needs the
// host bytes of a set_shader_buffers(Uniform) range (D-B8).
kCapNeedsHostUboBytes = 1ull << 8,
};
struct MGPCaps {
// The ~90 flat scalars the backends already publish, by inclusion rather than by
// restatement: a caps field added there must not need a second edit here. This is
// also where the six per-axis compute limits (MaxComputeWorkGroupCount/Size) ride -
// the only indexed answers the device owns, and therefore the only ones that outlive
// the GetIntegeri_v table entry (see the PipeCalls.def footer).
DynamicBackendParameters Dynamic;
Uint64 CallMask; // MGPCapBit
// The two halves that are not flat PODs travel as blobs: the format capability
// cache holds Vector<Int> sample-count lists, and the renderer strings are
// Strings. Their serializers land with the transport (P5).
MGPBlobRef FormatCapabilities;
MGPBlobRef RendererInfo;
};
static_assert(std::is_trivially_copyable_v<MGPCaps>, "MGPCaps must be trivially copyable");
// Stated as a COMPOSITION rather than a literal: DynamicBackendParameters still carries
// SizeT fields, so its literal size is ABI-dependent until P0.5 moves the caps block
// into MGPipeValueTypes.h with fixed-width members. The assertion still fires on any
// padding introduced between the members below.
static_assert(sizeof(MGPCaps) == sizeof(DynamicBackendParameters) + 8 + 24 + 24,
"MGPCaps gained padding or a member; update the wire format");
// Discriminated resource descriptor: buffers, every texture target and renderbuffers
// share one create/respecify shape (section 4.5.1).
struct MGPResourceDesc {
MGPipeHandle Resource;
Uint8 Target; // Buffer | Tex1D..TexCubeArray | Tex2DMS.. | Renderbuffer | TexBuffer
Uint8 StorageKind; // == TextureStorageType (Mipmap | Buffer)
// VERTEX|INDEX|CONSTANT|SHADER_BUFFER|INDIRECT|SAMPLER|SHADER_IMAGE|RENDER_TARGET|
// DEPTH_STENCIL|STREAM_OUTPUT|ATOMIC|ELEMENT_ARRAY. The ELEMENT_ARRAY bit is the
// D-B7 switch: with kCapNeedsHostIndexBytes set the server mirrors this resource.
Uint16 BindMask;
Uint32 InternalFormat; // already resolved to an uncompressed fallback by the client
Uint32 Width, Height, Depth;
Uint16 ArrayLayers, Levels, Samples;
Uint8 FixedSampleLocations, Immutable;
Uint32 Usage; // BufferUsage
Uint32 StorageFlags; // glBufferStorage flags
Uint8 HasDefinedContent; // false after a NULL-data respecify
Uint8 ImageBindableHint; // client-side everImageBound; pre-emptive allocation
Uint16 Pad0;
// Diagnostics only. A GL name is NEVER an identity, never a memo key and never part
// of a content hash (section 4.2.1). Widened from the plan's two bytes, which
// cannot hold one.
Uint32 GlNameForDiag;
Uint32 Pad1;
MGPipeHandle ViewOf; // storage owner for a texture view
MGPipeHandle BufferForTexBuffer; // texture-buffer backing store
Uint64 BufOffset, BufSize; // kWholeBuffer == ~0, resolved live
};
MGP_ASSERT_POD(MGPResourceDesc, 88);
inline constexpr Uint64 kMGPipeWholeBuffer = ~0ull;
struct MGPFenceWait {
MGPipeHandle Fence;
Uint64 TimeoutNs;
};
MGP_ASSERT_POD(MGPFenceWait, 16);
struct MGPQueryDesc {
MGPipeHandle Query;
Uint32 Kind; // GL query target
Uint32 Stream; // indexed query stream, 0 otherwise
};
MGP_ASSERT_POD(MGPQueryDesc, 16);
struct MGPQueryResultRequest {
MGPipeHandle Query;
Uint8 Wait; // the two-value contract of GetSyncStatus is preserved verbatim
Uint8 Pad0[3];
Uint32 Pad1;
};
MGP_ASSERT_POD(MGPQueryResultRequest, 16);
// query_timestamp: glGetInteger64v(GL_TIMESTAMP), the synchronous "what time is it on the
// GPU" GLFunctionsTable::GetGpuTimestampNs answers today. The request names nothing; the
// Int64 nanosecond stamp comes back through the reply slot.
struct MGPTimestampRequest {
Uint32 Reserved;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPTimestampRequest, 8);
// ---------------------------------------------------------------------------------
// CSOs
// ---------------------------------------------------------------------------------
// create_render_state carries ONLY the pipeline subset's chunk bytes. chunkMask lets an
// incremental create send just the chunks that moved, against baseCso (section 4.5.2).
struct MGPRenderStateDesc {
MGPipeHandle Cso;
MGPipeHandle BaseCso;
Uint32 ChunkMask; // all ones for a brand new CSO
Uint32 Pad0;
MGPBlobRef Blob;
};
MGP_ASSERT_POD(MGPRenderStateDesc, 48);
// Steady state: 12 bytes on the wire, no hashing, no blob.
struct MGPBindRenderState {
MGPipeHandle Cso;
Uint16 Version;
Uint16 PipelineVersion;
};
MGP_ASSERT_POD(MGPBindRenderState, 12);
// The half of the render state that must NOT mint a CSO: viewport, scissor, depth
// range, blend colour, line width, polygon offset, stencil ref/write mask, clear
// values, sample coverage, hints and the point-size family. This is what keeps
// glViewport from evicting Magma's pipeline memo (D-B1).
struct MGPDynamicState {
Uint32 ChunkMask;
Uint16 Version;
Uint16 Pad0;
MGPBlobRef Blob;
};
MGP_ASSERT_POD(MGPDynamicState, 32);
// Both views travel, and neither is derivable from the other: the resolved
// VertexAttribute[32] AND the binding points, because a pointer-call stride of 0 means
// "element size" while a binding-model stride of 0 means "every vertex reads the same
// element" (section 4.5.3). IsLong and Type == Float64 are carried separately.
struct MGPVertexElements {
MGPipeHandle Cso;
Uint32 AttributeCount;
Uint32 BindingPointCount;
MGPBlobRef Blob; // VertexAttribute[] followed by VertexBufferBindingPoint[]
};
MGP_ASSERT_POD(MGPVertexElements, 40);
// SamplerParameters crosses byte for byte INCLUDING borderColorForm: without it the
// backend cannot choose between glSamplerParameterIiv and fv, or between the
// VkBorderColor families, because all three representations are always numerically
// populated (section 4.5.4). Carried as a blob until P0.5 gives it a value header.
struct MGPSamplerDesc {
MGPipeHandle Cso;
MGPBlobRef Parameters;
};
MGP_ASSERT_POD(MGPSamplerDesc, 32);
// = pipe_sampler_view, and ONLY the view restrictions. Everything a glTexParameter
// writes lives on set_texture_params instead, because a texture that is only an FBO
// attachment, only an image binding or only a glCopyImageSubData endpoint has no
// sampler view to hang it on (section 4.4.3).
struct MGPSamplerView {
MGPipeHandle Cso;
MGPipeHandle Texture;
Uint32 InternalFormat; // aliasing format for glTextureView
Uint8 Target;
Uint8 Pad0[3];
Uint16 MinLevel, NumLevels, MinLayer, NumLayers;
Uint16 Samples;
Uint8 FixedSampleLocations;
Uint8 Pad1;
};
MGP_ASSERT_POD(MGPSamplerView, 36);
// Per texture OBJECT, independent of any view.
struct MGPTextureParams {
MGPipeHandle Res;
Uint16 BaseLevel, MaxLevel;
Uint8 Swizzle[4];
Uint8 DepthStencilMode;
// Mirrors m_forceTextureParamsResync: the widened-channel carrier needs a swizzle
// override that the frontend params version does not move for.
Uint8 ForceResync;
Uint8 Pad0[2];
Float MinLod, MaxLod, LodBias;
};
MGP_ASSERT_POD(MGPTextureParams, 32);
// create_shader_state. The reflection blob is the whole LinkArtifacts + SpirvArtifacts
// archive; P0.5 extracts those types out of ProgramObject.h so a server can
// deserialize into them without dragging in glslang (section 4.5.5).
struct MGPProgramDesc {
MGPipeHandle Cso;
Uint32 StageMask; // == GetLinkedShaderStages()
Uint32 GlobalUboSize;
Uint32 ReservedNumSamplesOffset;
Uint8 SpirvStatus;
Uint8 NativeFloat64;
Uint8 PointSizeDemoted;
Uint8 EnableSpirvValidation;
MGPBlobRef Spirv[6]; // per stage
MGPBlobRef Reflection;
};
MGP_ASSERT_POD(MGPProgramDesc, 192);
// ---------------------------------------------------------------------------------
// set_*
// ---------------------------------------------------------------------------------
// = pipe_surface. internalFormat is INLINE so the four cross-object masks fall out at
// push time with no lookup (section 4.5.6).
struct MGPSurface {
MGPipeHandle Res;
Uint32 InternalFormat;
Uint8 Kind; // Texture | Renderbuffer | None
Uint8 Layered;
Uint16 Level;
Uint32 Layer;
Uint16 UploadTarget;
Uint16 Pad0;
};
MGP_ASSERT_POD(MGPSurface, 24);
struct MGPFramebufferState {
MGPipeHandle Fbo; // kMGPipeDefaultFramebuffer for the default framebuffer
MGPSurface Color[8];
MGPSurface Depth, Stencil;
// The RESOLVED read surface, not an index. This is what structurally closes the
// read-buffer-shared-FBO defect class.
MGPSurface ReadSurface;
Int8 DrawBuffers[8]; // attachment index, -1 = NONE
Uint16 Width, Height, Layers, Samples;
Uint8 FixedSampleLocations, IsDefault, Complete, Pad0;
Uint32 Pad1;
// Two jobs (section 4.5.6): the server's render-pass memo key, and the CLIENT's
// emission suppressor - an unchanged hash means this record is not sent at all.
// The same pattern is mandatory for every kVarTail set_* below, or 26.2's
// redundant glBindSampler traffic reappears as a variable-length record per batch.
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPFramebufferState, 304);
struct MGPVertexBuffer {
MGPipeHandle Res;
Uint64 Offset;
Uint32 Stride;
Uint32 Divisor;
Uint32 BindingIndex;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPVertexBuffer, 32);
// Var-tail header: MGPVertexBuffer[Count] follows.
struct MGPVertexBuffers {
Uint32 Start, Count;
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPVertexBuffers, 16);
// An independent call, NOT a subset of the VAO configuration version (D5).
struct MGPIndexBuffer {
MGPipeHandle Res;
Uint64 Offset;
Uint32 IndexSize;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPIndexBuffer, 24);
struct MGPIndirectBuffers {
MGPipeHandle DrawIndirect;
MGPipeHandle Parameter;
};
MGP_ASSERT_POD(MGPIndirectBuffers, 16);
// One entry of set_sampler_views. No stage dimension: MobileGL's texture unit space is
// MERGED (TextureState::m_textureUnits is one Array of MAX_TEXTURE_IMAGE_UNITS = 192),
// and the same unit may be sampled from two stages (section 4.4.3).
struct MGPBoundView {
MGPipeHandle View;
MGPipeHandle Texture;
Uint32 Unit;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPBoundView, 24);
struct MGPSamplerViews {
Uint32 Start, Count;
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPSamplerViews, 16);
// Var-tail header: MGPipeHandle[Count] of sampler CSOs follows.
struct MGPSamplerStates {
Uint32 Start, Count;
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPSamplerStates, 16);
struct MGPImageView {
MGPipeHandle Res;
Uint32 Unit;
Uint32 InternalFormat;
Uint32 Layer;
Uint16 Level;
Uint8 Layered;
Uint8 Access;
};
MGP_ASSERT_POD(MGPImageView, 24);
struct MGPShaderImages {
Uint32 Start, Count;
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPShaderImages, 16);
// One bound buffer range: 24 bytes, no inline host span. The named-UBO host bytes a
// backend needs under kCapNeedsHostUboBytes (D-B8) travel as an OPTIONAL second var-tail,
// MGHostSpan[HostSpanCount] behind the ranges, announced by MGPShaderBuffers below. An
// inline span would have cost every SSBO, atomic-counter and XFB range 32 dead bytes, and
// D-B8 says not to freeze that payload's shape before the stage-ubo-named counter has
// produced numbers.
struct MGPBufferRange {
MGPipeHandle Res;
Uint64 Offset;
Uint64 Size;
};
MGP_ASSERT_POD(MGPBufferRange, 24);
// Var-tail header: MGPBufferRange[Count], then MGHostSpan[HostSpanCount]. HostSpanCount is
// 0, or Count for the Uniform class under kCapNeedsHostUboBytes (a range with nothing to
// ship carries an empty span, so the two arrays stay index-aligned).
struct MGPShaderBuffers {
Uint32 Class; // Uniform | ShaderStorage | AtomicCounter
Uint32 Start;
Uint32 Count;
Uint32 WritableMask;
Uint32 HostSpanCount; // 0, or Count when the kHostSpan tail is present (D-B8)
Uint32 Pad0;
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPShaderBuffers, 32);
// Var-tail header: MGPBufferRange[Count] then Uint32 offsets[Count].
struct MGPStreamOutputTargets {
Uint32 Count;
Uint32 Pad0;
Uint64 Generation;
Uint64 ContentHash;
};
MGP_ASSERT_POD(MGPStreamOutputTargets, 24);
// Covers the DEFAULT UNIFORM BLOCK only (D6).
struct MGPGlobalConstants {
MGPipeHandle ShaderCso;
Uint32 Version;
Uint32 Pad0;
MGPBlobRef Blob;
};
MGP_ASSERT_POD(MGPGlobalConstants, 40);
// The float/int/uint view is resolved on the CLIENT by ClassifyVertexAttribType.
struct MGPAttribValue {
Uint32 Location;
Uint8 ValueClass; // Float | Int | Uint | Double
Uint8 Pad0[3];
Uint32 Data[4];
};
MGP_ASSERT_POD(MGPAttribValue, 24);
// Var-tail header: MGPAttribValue[popcount(Mask)] follows.
struct MGPVertexAttribDefaults {
Uint32 Mask;
Uint32 Count;
};
MGP_ASSERT_POD(MGPVertexAttribDefaults, 8);
// PACK only. There is deliberately no unpack counterpart: nothing on the far side of
// the boundary reads unpack state (section 4.6 D5), and the staged-repack upload path
// does not even issue glPixelStorei.
struct MGPPixelPackState {
PixelStoreParameters Pack;
};
static_assert(std::is_trivially_copyable_v<MGPPixelPackState>);
// 28 is what PixelStoreParameters measures: two Bools, two bytes of padding, six Ints.
// Asserting against sizeof(PixelStoreParameters) itself was a tautology that could not
// notice the value struct changing width under the wire format.
static_assert(sizeof(MGPPixelPackState) == 28,
"MGPPixelPackState changed size; update the wire format and this assertion");
// Also a shader-variant input: both backends bake these into the synthesized
// pass-through control stage.
struct MGPPatchState {
Uint32 Vertices;
Uint32 Pad0;
Float Outer[4];
Float Inner[2];
Uint32 Pad1[2];
};
MGP_ASSERT_POD(MGPPatchState, 40);
// Migration-only (section 6.3). Every stage removes fields and lowers
// MGL_RESIDUAL_BLOCK_SIZE; P13 asserts it is zero, which is the retirement trip wire.
//
// Layout must be asserted MEMBER BY MEMBER, not only by sizeof: a heterogeneous POD
// union is where padding differs across ABIs, and the monolith verify harness is blind
// to it because both sides are the same translation unit. G3 emits the offsetof
// assertions; under split the block is serialized field-wise rather than memcpy'd.
struct ResidualValueBlock {
RenderStateParameters RenderState; // until create/bind_render_state + set_dynamic_state land
PixelStoreParameters Pack; // until set_pixel_pack_state lands
Uint64 CapabilityBits;
Uint32 PatchVertices;
Uint32 Pad0;
Float PatchOuter[4];
Float PatchInner[2];
Uint32 Pad1[2];
};
static_assert(std::is_trivially_copyable_v<ResidualValueBlock>);
// The retirement ratchet. This number only ever goes DOWN: every stage that lands a real
// set_* call deletes fields here and lowers it, and P13 replaces it with
// static_assert(sizeof(ResidualValueBlock) == 0), which stays red until the last field is
// gone. Shrinking the block without lowering the number, or growing it at all, is a build
// break - which is the point.
//
// Stable across the ABIs MobileGL ships on: every member of RenderStateParameters and
// PixelStoreParameters is a fixed-width scalar or an array of one, with no pointer and no
// SizeT.
#define MGL_RESIDUAL_BLOCK_SIZE 1248
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE,
"the residual value block changed size; lower MGL_RESIDUAL_BLOCK_SIZE if a field "
"retired, and do not raise it");
struct MGPResidualValueState {
Uint32 Version;
Uint32 Pad0;
MGPBlobRef Blob;
};
MGP_ASSERT_POD(MGPResidualValueState, 32);
// ---------------------------------------------------------------------------------
// Transfer
// ---------------------------------------------------------------------------------
// Shape copied from the unpack ring's existing UnpackStagingBlock. The source strides
// are CARRIED, not inferred from a pointer comparison: the old
// `uploadData == mipData` test cannot survive a split, where the client neither ships
// the whole level nor keeps a server-side mirror of it (section 4.5.6).
struct MGPSubRegion {
Int32 X, Y, Z;
Uint32 W, H, D;
Uint64 SrcOffset; // into the blob
Uint32 SrcRowStride; // bytes; 0 = tightly packed (w * bpp)
Uint32 SrcSliceStride; // bytes; 0 = tightly packed
};
MGP_ASSERT_POD(MGPSubRegion, 40);
// Carries 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. Mali prices texture upload by
// JOB COUNT: ~100 sprite rects against one union box measured +6 ms/frame.
//
// THE BUFFER HALF. With Target == Buffer there is no level and no box, so the destination
// byte range rides in the box's first coordinate and first extent: UnionBox.X is the byte
// offset, UnionBox.W the byte size, Y = Z = 0, H = D = 1, Level = 0, RegionCount = 0, and
// Blob holds exactly Size source bytes. That caps ONE record at a 2^31-1 offset and a
// 2^32-1 size; a range beyond either is split by the emitter - the same rule, and at
// SEG_STAGE's 32 MiB the far tighter one, that the ring's half-capacity bound already
// imposes on it. MGPipeSetSubDataBufferRange / MGPipeSubDataBufferOffset / Size below are
// the only spelling of this convention; nothing else reads the box for a buffer.
struct MGPSubData {
MGPipeHandle Res;
Uint16 Target, Level;
// Replaces the backend's `uploadData == mipData` pointer comparison: are these
// bytes an untransformed level shadow?
Uint8 SourceIsVerbatimLevelShadow;
Uint8 Pad0[3];
MGPBox UnionBox;
Uint32 RegionCount; // MGPSubRegion[] in the variable tail
Uint32 Pad1;
MGPBlobRef Blob;
};
MGP_ASSERT_POD(MGPSubData, 72);
// Encodes a buffer byte range into the record's box. False, with the record untouched,
// when the range does not fit one record: the emitter has to split it.
inline Bool MGPipeSetSubDataBufferRange(MGPSubData& record, Uint64 offset, Uint64 size) {
if (offset > 0x7FFFFFFFull || size > 0xFFFFFFFFull) {
return false;
}
record.UnionBox = MGPBox{static_cast<Int32>(offset), 0, 0, static_cast<Uint32>(size), 1, 1};
record.Level = 0;
record.RegionCount = 0;
return true;
}
inline Uint64 MGPipeSubDataBufferOffset(const MGPSubData& record) {
// A negative X is a corrupt record (the encoder never writes one); read as unsigned
// it lands above the encodable bound, which the applier's bounds gate refuses.
return static_cast<Uint64>(static_cast<Uint32>(record.UnionBox.X));
}
inline Uint64 MGPipeSubDataBufferSize(const MGPSubData& record) { return record.UnionBox.W; }
// The forward terminator for a server-initiated texture pull (section 7.1). May carry
// zero regions - that is how a pull that needs nothing is answered.
struct MGPSubDataComplete {
MGPipeHandle Res;
Uint16 Target, FirstLevel, LevelCount, Pad0;
Uint64 PullSerial;
};
MGP_ASSERT_POD(MGPSubDataComplete, 24);
// Carries the application's REAL access flags, not a normalized subset.
struct MGPFlushRange {
MGPipeHandle Res;
Uint64 Offset, Size;
Uint32 AccessFlags; // Flags<BufferMappingAccessBit>
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPFlushRange, 32);
struct MGPReadback {
MGPipeHandle Res;
Uint64 Offset, Size;
};
MGP_ASSERT_POD(MGPReadback, 24);
struct MGPCopyRegion {
MGPipeHandle Src, Dst;
MGPBox SrcBox;
Int32 DstX, DstY, DstZ;
Uint16 SrcTarget, DstTarget;
Uint16 SrcLevel, DstLevel;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPCopyRegion, 64);
struct MGPBlit {
MGPipeHandle ReadFbo, DrawFbo;
Int32 SrcX0, SrcY0, SrcX1, SrcY1;
Int32 DstX0, DstY0, DstX1, DstY1;
Uint32 Mask;
Uint32 Filter;
};
MGP_ASSERT_POD(MGPBlit, 56);
// One discriminated record replacing glClear, the four glClearBuffer* and the four
// glClearNamedFramebuffer* entry points (section 4.4.4).
struct MGPClear {
MGPipeHandle Fbo;
Uint32 Kind; // Whole | Color | Depth | Stencil | DepthStencil
Int32 DrawBufferIndex;
Uint32 BufferMask; // GL_COLOR_BUFFER_BIT etc. for the whole-framebuffer form
Uint32 ValueClass; // Float | Int | Uint
Uint32 ColorValue[4];
Float DepthValue;
Int32 StencilValue;
};
MGP_ASSERT_POD(MGPClear, 48);
struct MGPMipPlan {
MGPipeHandle Res;
Uint16 Target, BaseLevel, LevelCount, Pad0;
};
MGP_ASSERT_POD(MGPMipPlan, 16);
// read_pixels and get_texture_image share one shape; both answer into a reply slot.
struct MGPReadbackInfo {
MGPipeHandle Res; // null for read_pixels: the bound read surface answers
MGPBox Box;
Uint32 Format, Type;
Uint16 Target, Level;
Uint32 Pad0;
Uint64 DstOffset, DstSize;
};
MGP_ASSERT_POD(MGPReadbackInfo, 64);
// ---------------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------------
enum MGPDrawFlagBit : Uint8 {
kDrawHasUserIndices = 1u << 0,
kDrawPrimitiveRestart = 1u << 1,
kDrawIndicesAreClient = 1u << 2,
kDrawHasIndexRange = 1u << 3,
kDrawHasXfbCount = 1u << 4,
};
// = pipe_draw_info. Today's twenty draw entry points collapse onto this one call, with
// MGPDrawRange[] holding exactly the shape the glMultiDraw* family already has.
//
// minIndex/maxIndex are computed only on the client-memory array path today, and
// xfbCpuCapturedVertices only on the XFB scatter path, so Flags gates the WORK. They
// stay in the fixed head; moving them into the variable tail is a wire-format decision
// that belongs with the transport (P5), where per-draw byte histograms exist to size
// it. userIndices is in the variable tail already, so the VBO path - every Minecraft
// and Sodium draw - never pays the 32 bytes of an MGHostSpan.
struct MGPDrawInfo {
Uint32 Mode;
Uint8 IndexSize; // 0 = arrays, else 1 / 2 / 4
Uint8 Flags; // MGPDrawFlagBit
Uint16 Pad0;
Uint32 InstanceCount, StartInstance;
Uint32 RestartIndex;
Uint32 DrawIdOffset;
MGPipeHandle IndexResource;
Uint32 MinIndex, MaxIndex; // ~0 = unknown
Uint64 XfbCpuCapturedVertices;
Uint32 NumDraws; // MGPDrawRange[] in the variable tail
Uint32 Pad1;
};
MGP_ASSERT_POD(MGPDrawInfo, 56);
// = pipe_draw_start_count_bias.
struct MGPDrawRange {
Uint32 Start, Count;
Int32 IndexBias;
};
MGP_ASSERT_POD(MGPDrawRange, 12);
// Present when the draw is indirect. The client resolves the COUNT itself, so the
// server never reads an indirect command block to learn how many draws there are.
struct MGPDrawIndirect {
MGPipeHandle Buffer;
MGPipeHandle ParameterBuffer;
Uint64 Offset, ParameterOffset;
Uint32 Stride, DrawCount;
};
MGP_ASSERT_POD(MGPDrawIndirect, 40);
struct MGPGridInfo {
Uint32 GridX, GridY, GridZ;
Uint32 BlockX, BlockY, BlockZ;
MGPipeHandle IndirectBuffer;
Uint64 IndirectOffset;
Uint8 IsIndirect;
Uint8 Pad0[7];
};
MGP_ASSERT_POD(MGPGridInfo, 48);
struct MGPMemoryBarrier {
Uint32 Bits; // GLbitfield
Uint8 ByRegion;
Uint8 Pad0[3];
};
MGP_ASSERT_POD(MGPMemoryBarrier, 8);
struct MGPStreamOutputBegin {
Uint32 PrimitiveMode;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPStreamOutputBegin, 8);
// end_stream_output carries the accounting the client owns; the scatter itself is a
// read-modify-write of the client's shadow and lives there (section 7.2.1).
struct MGPXfbAccounting {
Uint64 CapturedVertices;
Uint64 PrimitivesWritten;
Uint32 PrimitiveMode;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPXfbAccounting, 24);
struct MGPStreamOutputControl {
Uint32 Reserved;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPStreamOutputControl, 8);
struct MGPFlush {
Uint32 Flags;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPFlush, 8);
struct MGPPresent {
Uint64 FrameSerial;
};
MGP_ASSERT_POD(MGPPresent, 8);
struct MGPSwapInterval {
Int32 Interval;
Uint32 Pad0;
};
MGP_ASSERT_POD(MGPSwapInterval, 8);
// ---------------------------------------------------------------------------------
// Reverse channel payloads (section 7.1)
// ---------------------------------------------------------------------------------
struct MGPSurfaceInfo {
Uint32 Width, Height;
Uint32 InternalFormat;
Uint16 Samples, Layers;
Uint8 IsDefault;
Uint8 Pad0[7];
};
MGP_ASSERT_POD(MGPSurfaceInfo, 24);
#undef MGP_ASSERT_POD
} // namespace MobileGL::MG_Pipe
+177
View File
@@ -0,0 +1,177 @@
// MobileGL - MobileGL/MG_Pipe/PipeCalls.def
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// The single source of truth for the MGPipe call catalogue (plan B section 4.1 / 4.4 /
// appendix A). One line per call; seven generators consume this file
// (scripts/gen_pipe.py -> MG_Pipe/generated/*.inc) and one unit test
// (MG_Test/Pipe/PipeCatalogueTest.cpp) pins the arithmetic.
//
// X(Name, PayloadStruct, Class, Flags)
// Class : kScreen | kCtxCso | kCtxState | kCtxObject | kCtxVerb | kCtxQuery
// kScreen lands in struct MGPipeScreen, every other class in struct
// MGPipeContext (plan section 4.3).
// Flags : kNone | kNeedsAck | kHasBlob | kVarTail | kHostSpan | kReplySlot | kOptional
//
// RECORD NUMBERING NEVER CHURNS. Entries that are not implemented yet still occupy their
// line (plan section 11, P0: "the complete call catalogue, placeholders included"). A new
// call is APPENDED to its group; a retired call keeps its slot with a comment. The wire
// opcode is the 1-based position in this list, so reordering is a protocol break.
//
// ---------------------------------------------------------------------------------------
// COUNTS. MGP_CALL_LIST_DOCUMENTED_COUNT below is the authority; PipeCatalogueTest asserts
// that the expansion, the two generated tables and this number agree.
//
// class entries group (as the plan tabulates it)
// kScreen 11 screen: caps 1 + resource 3 + persistent map 2 + fence 4, plus the
// appended server-side fence wait 1
// kCtxQuery 8 query object namespace 6, plus the appended timestamp pair 2
// kCtxCso 13 CSO create/bind/delete
// kCtxState 17 16 of the 17 set_* calls + the temporary set_residual_value_state
// kCtxObject 9 set_texture_params (the 17th set_*) + 8 object-scoped transfers
// kCtxVerb 13 3 context-reading transfer calls + the 10 commands
// total 71
//
// Reconciliation with the plan's headline numbers (section 4.4 / appendix A), because they
// do not add up to a set of UNIQUE records and this file has to hold unique records:
// - "screen 14" tabulates the fence and query families together with the screen block.
// Section 4.3 assigns the query NAMESPACE to the context ("VAO / FBO / XFB object /
// query namespaces, the command stream, present"), so the six query calls carry
// kCtxQuery and live in MGPipeContext. Screen keeps 10 of the plan's (11 with the appended
// FenceWaitServer, below). The eight EGL lifecycle entry points stay virtual functions on
// pActiveBackendObject and are deliberately NOT calls here (section 4.4.1, last row).
// - "CSO 15" is create/bind/delete x 5 kinds. Two of those binds are ALSO named in the
// set_* catalogue as their array forms - bind_sampler_states and set_sampler_views
// (section 4.4.3) - and a call may only exist once, so they are emitted under
// kCtxState and the CSO group holds 13: create/delete x 5 plus the three remaining
// binds (render state, vertex elements, shader).
// - "transfer 12" enumerates 11 calls in section 4.4.4 plus appendix A
// (resource_subdata, buffer_subdata_resident, resource_flush_range, resource_readback,
// resource_copy_region, blit, clear, generate_mipmap, read_pixels, get_texture_image,
// resource_subdata_complete). Eleven is what is emitted; the twelfth is not named
// anywhere in the plan.
// - "about 74 items" in section 4.1 is the sum of those headline numbers, so it inherits
// the same double counting. 68 unique records was the honest total of the plan's own
// catalogue.
// - Three LIVE GLFunctionsTable entries had no carrier in it at all: GetGpuTimestampNs
// (glGetInteger64v(GL_TIMESTAMP), a synchronous server answer), QueryCounterTimestamp
// (glQueryCounter, a one-shot stamp rather than a begin/end pair) and WaitSync (the
// GPU-side wait, which FenceWait's client-side wait does not express). They are
// QueryTimestamp, QueryCounter and FenceWaitServer, APPENDED at the end of the list -
// not slotted into their groups - because the wire opcode is the position, so a record
// that arrives late goes last. 71 unique records.
// ---------------------------------------------------------------------------------------
#define MGP_CALL_LIST_DOCUMENTED_COUNT 71
// clang-format off
#define MGP_CALL_LIST(X) \
/* ---- screen: caps, resources, persistent map, fences (plan 4.4.1) ---- */ \
X(GetCaps, MGPCaps, kScreen, kReplySlot) \
X(ResourceCreate, MGPResourceDesc, kScreen, kNone) \
X(ResourceRespecify, MGPResourceDesc, kScreen, kNone) \
X(ResourceDestroy, MGPHandleOnly, kScreen, kNone) \
X(MapPersistent, MGPHandleOnly, kScreen, kReplySlot|kOptional) \
X(UnmapPersistent, MGPHandleOnly, kScreen, kOptional) \
X(FenceCreate, MGPHandleOnly, kScreen, kNone) \
X(FenceStatus, MGPHandleOnly, kScreen, kReplySlot) \
X(FenceWait, MGPFenceWait, kScreen, kReplySlot) \
X(FenceDestroy, MGPHandleOnly, kScreen, kNone) \
/* ---- context: query objects (plan 4.3 gives the namespace to the context) ---- */ \
X(QueryCreate, MGPQueryDesc, kCtxQuery, kNone) \
X(QueryBegin, MGPQueryDesc, kCtxQuery, kNone) \
X(QueryEnd, MGPQueryDesc, kCtxQuery, kNone) \
X(QueryAvailable, MGPHandleOnly, kCtxQuery, kReplySlot) \
X(QueryResult, MGPQueryResultRequest, kCtxQuery, kReplySlot) \
X(QueryDestroy, MGPHandleOnly, kCtxQuery, kNone) \
/* ---- context: CSO create/bind/delete (plan 4.4.2, 4.5.2-4.5.5) ---- */ \
X(CreateRenderState, MGPRenderStateDesc, kCtxCso, kHasBlob) \
X(BindRenderState, MGPBindRenderState, kCtxCso, kNone) \
X(DeleteRenderState, MGPHandleOnly, kCtxCso, kNone) \
X(CreateVertexElements, MGPVertexElements, kCtxCso, kHasBlob) \
X(BindVertexElements, MGPHandleOnly, kCtxCso, kNone) \
X(DeleteVertexElements, MGPHandleOnly, kCtxCso, kNone) \
X(CreateSamplerState, MGPSamplerDesc, kCtxCso, kNone) \
X(DeleteSamplerState, MGPHandleOnly, kCtxCso, kNone) \
X(CreateSamplerView, MGPSamplerView, kCtxCso, kNone) \
X(DeleteSamplerView, MGPHandleOnly, kCtxCso, kNone) \
X(CreateShaderState, MGPProgramDesc, kCtxCso, kHasBlob) \
X(BindShaderState, MGPHandleOnly, kCtxCso, kNone) \
X(DeleteShaderState, MGPHandleOnly, kCtxCso, kNone) \
/* ---- context: set_* (plan 4.4.3) ---- */ \
X(SetDynamicState, MGPDynamicState, kCtxState, kHasBlob) \
X(SetFramebufferState, MGPFramebufferState, kCtxState, kNone) \
X(SetVertexBuffers, MGPVertexBuffers, kCtxState, kVarTail) \
X(SetIndexBuffer, MGPIndexBuffer, kCtxState, kNone) \
X(SetIndirectBuffers, MGPIndirectBuffers, kCtxState, kNone) \
X(SetSamplerViews, MGPSamplerViews, kCtxState, kVarTail) \
X(BindSamplerStates, MGPSamplerStates, kCtxState, kVarTail) \
X(SetShaderImages, MGPShaderImages, kCtxState, kVarTail) \
X(SetShaderBuffers, MGPShaderBuffers, kCtxState, kVarTail|kHostSpan) \
X(SetStreamOutputTargets, MGPStreamOutputTargets, kCtxState, kVarTail) \
X(SetGlobalConstants, MGPGlobalConstants, kCtxState, kHasBlob) \
X(SetVertexAttribDefaults, MGPVertexAttribDefaults, kCtxState, kVarTail) \
X(SetPixelPackState, MGPPixelPackState, kCtxState, kNone) \
X(SetPatchState, MGPPatchState, kCtxState, kNone) \
X(SetDrawProgram, MGPHandleOnly, kCtxState, kNone) \
X(SetDispatchProgram, MGPHandleOnly, kCtxState, kNone) \
/* Migration-only carrier for Track V, retired field by field across P2..P13. Its */ \
/* retirement is a compile error: MGL_RESIDUAL_BLOCK_SIZE only ever goes DOWN and the */ \
/* final step asserts sizeof(ResidualValueBlock) == 0 (plan 6.3). */ \
X(SetResidualValueState, MGPResidualValueState, kCtxState, kHasBlob) \
/* ---- context: per-object state and transfer (plan 4.4.3 set_texture_params, 4.4.4) ---- */ \
X(SetTextureParams, MGPTextureParams, kCtxObject, kNone) \
X(ResourceSubData, MGPSubData, kCtxObject, kHasBlob|kVarTail) \
X(BufferSubDataResident, MGPSubData, kCtxObject, kHasBlob|kOptional) \
X(ResourceSubDataComplete, MGPSubDataComplete, kCtxObject, kNone) \
X(ResourceFlushRange, MGPFlushRange, kCtxObject, kNone) \
X(ResourceReadback, MGPReadback, kCtxObject, kReplySlot) \
X(ResourceCopyRegion, MGPCopyRegion, kCtxObject, kNone) \
X(GenerateMipmap, MGPMipPlan, kCtxObject, kNone) \
X(GetTextureImage, MGPReadbackInfo, kCtxObject, kReplySlot) \
/* ---- context: transfer calls that read whole-context state, and the commands ---- */ \
X(Blit, MGPBlit, kCtxVerb, kNone) \
X(Clear, MGPClear, kCtxVerb, kNone) \
X(ReadPixels, MGPReadbackInfo, kCtxVerb, kReplySlot) \
X(DrawVbo, MGPDrawInfo, kCtxVerb, kHostSpan|kVarTail) \
X(LaunchGrid, MGPGridInfo, kCtxVerb, kNone) \
X(MemoryBarrier, MGPMemoryBarrier, kCtxVerb, kNone) \
X(BeginStreamOutput, MGPStreamOutputBegin, kCtxVerb, kNone) \
X(EndStreamOutput, MGPXfbAccounting, kCtxVerb, kNone) \
X(PauseStreamOutput, MGPStreamOutputControl, kCtxVerb, kNone) \
X(ResumeStreamOutput, MGPStreamOutputControl, kCtxVerb, kNone) \
X(Flush, MGPFlush, kCtxVerb, kNone) \
X(Present, MGPPresent, kCtxVerb, kNone) \
X(SetSwapInterval, MGPSwapInterval, kCtxVerb, kOptional) \
/* ---- APPENDED. Opcodes are positional, so a late arrival goes at the END, never into ---- */ \
/* ---- its group: three live GLFunctionsTable entries the catalogue had no carrier for. ---- */ \
/* glGetInteger64v(GL_TIMESTAMP) - GetGpuTimestampNs, a synchronous server answer, which */ \
/* the reply slot carries. The query namespace is the context's (plan 4.3). */ \
X(QueryTimestamp, MGPTimestampRequest, kCtxQuery, kReplySlot) \
/* glQueryCounter(GL_TIMESTAMP) - QueryCounterTimestamp, a one-shot stamp into a query */ \
/* object, NOT a begin/end pair. Kind carries GL_TIMESTAMP. */ \
X(QueryCounter, MGPQueryDesc, kCtxQuery, kNone) \
/* glWaitSync - WaitSync, the GPU-side wait, distinct from FenceWait's client-side one. */ \
/* TimeoutNs is GL_TIMEOUT_IGNORED by contract. */ \
X(FenceWaitServer, MGPFenceWait, kScreen, kNone)
// clang-format on
// Explicitly NOT migrated (plan 4.4.6 / appendix A "explicit deletions"):
// - GetIntegeri_v / GetInteger64i_v. The six backend-owned answers they carry -
// GL_MAX_COMPUTE_WORK_GROUP_COUNT and GL_MAX_COMPUTE_WORK_GROUP_SIZE, three axes each,
// the only indexed pnames the device rather than the frontend answers - live in MGPCaps
// as DynamicBackendParameters::MaxComputeWorkGroupCount / MaxComputeWorkGroupSize, filled
// by both backends at capability init (DirectGLES from glGetIntegeri_v, DirectVulkan from
// VkPhysicalDeviceLimits) and floored by the frontend. Every other indexed pname names
// frontend state and is answered before any table is consulted.
// - GetProgramiv. GL_COMPUTE_WORK_GROUP_SIZE is a FRONTEND link artifact
// (ProgramObject::GetComputeLocalSize, what GL_Program.cpp has always answered from), not
// a backend answer at all; nothing a backend knows about a program crosses this way.
// - ShaderStorageBlockBinding (folded into MGPProgramDesc's reflection archive),
// set_pixel_unpack_state (no such state crosses the line - plan 4.6 D5), a
// compressed-format concept, pipe_transfer, and the stage dimension of set_sampler_views
// (MobileGL's texture unit space is merged, not per stage - plan 4.4.3).
+238
View File
@@ -0,0 +1,238 @@
// MobileGL - MobileGL/MG_Pipe/PipeFields.def
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// Field lists for the G4 shadow comparator (plan B section 10.3-2). One macro per payload
// in MGPipeTypes.h, listing the fields that carry MEANING - padding is deliberately absent,
// because MOBILEGL_PIPE_VERIFY has to have ZERO false positives and a padding byte is
// exactly what makes a memcmp of RenderStateParameters false-DIFFER
// (DirectGLES.cpp documents that behaviour where it does the same comparison itself).
//
// Hand maintained alongside MGPipeTypes.h. Adding a field to a payload without adding it
// here makes the comparator blind to it; that gap closes in P1, when the verify harness
// goes live and the comparator's coverage is itself asserted.
//
// clang-format off
#define MGP_FIELDS_MGPBlobRef(F) \
F(Offset) F(Size) F(Seg)
#define MGP_FIELDS_MGPRange(F) \
F(Offset) F(Size)
#define MGP_FIELDS_MGPBox(F) \
F(X) F(Y) F(Z) F(W) F(H) F(D)
#define MGP_FIELDS_MGPReplySlot(F) \
F(Id)
#define MGP_FIELDS_MGPStateChunk(F) \
F(Offset) F(Length)
#define MGP_FIELDS_MGPHandleOnly(F) \
F(Handle) F(Kind)
#define MGP_FIELDS_MGPCaps(F) \
F(Dynamic) F(CallMask) F(FormatCapabilities) F(RendererInfo)
#define MGP_FIELDS_MGPResourceDesc(F) \
F(Resource) F(Target) F(StorageKind) F(BindMask) F(InternalFormat) F(Width) F(Height) F(Depth) \
F(ArrayLayers) F(Levels) F(Samples) F(FixedSampleLocations) F(Immutable) F(Usage) F(StorageFlags) \
F(HasDefinedContent) F(ImageBindableHint) F(GlNameForDiag) F(ViewOf) F(BufferForTexBuffer) \
F(BufOffset) F(BufSize)
#define MGP_FIELDS_MGPFenceWait(F) \
F(Fence) F(TimeoutNs)
#define MGP_FIELDS_MGPQueryDesc(F) \
F(Query) F(Kind) F(Stream)
#define MGP_FIELDS_MGPQueryResultRequest(F) \
F(Query) F(Wait)
#define MGP_FIELDS_MGPTimestampRequest(F) \
F(Reserved)
#define MGP_FIELDS_MGPRenderStateDesc(F) \
F(Cso) F(BaseCso) F(ChunkMask) F(Blob)
#define MGP_FIELDS_MGPBindRenderState(F) \
F(Cso) F(Version) F(PipelineVersion)
#define MGP_FIELDS_MGPDynamicState(F) \
F(ChunkMask) F(Version) F(Blob)
#define MGP_FIELDS_MGPVertexElements(F) \
F(Cso) F(AttributeCount) F(BindingPointCount) F(Blob)
#define MGP_FIELDS_MGPSamplerDesc(F) \
F(Cso) F(Parameters)
#define MGP_FIELDS_MGPSamplerView(F) \
F(Cso) F(Texture) F(InternalFormat) F(Target) F(MinLevel) F(NumLevels) F(MinLayer) F(NumLayers) \
F(Samples) F(FixedSampleLocations)
#define MGP_FIELDS_MGPTextureParams(F) \
F(Res) F(BaseLevel) F(MaxLevel) F(Swizzle) F(DepthStencilMode) F(ForceResync) F(MinLod) F(MaxLod) \
F(LodBias)
#define MGP_FIELDS_MGPProgramDesc(F) \
F(Cso) F(StageMask) F(GlobalUboSize) F(ReservedNumSamplesOffset) F(SpirvStatus) F(NativeFloat64) \
F(PointSizeDemoted) F(EnableSpirvValidation) F(Spirv) F(Reflection)
#define MGP_FIELDS_MGPSurface(F) \
F(Res) F(InternalFormat) F(Kind) F(Layered) F(Level) F(Layer) F(UploadTarget)
#define MGP_FIELDS_MGPFramebufferState(F) \
F(Fbo) F(Color) F(Depth) F(Stencil) F(ReadSurface) F(DrawBuffers) F(Width) F(Height) F(Layers) \
F(Samples) F(FixedSampleLocations) F(IsDefault) F(Complete) F(ContentHash)
#define MGP_FIELDS_MGPVertexBuffer(F) \
F(Res) F(Offset) F(Stride) F(Divisor) F(BindingIndex)
#define MGP_FIELDS_MGPVertexBuffers(F) \
F(Start) F(Count) F(ContentHash)
#define MGP_FIELDS_MGPIndexBuffer(F) \
F(Res) F(Offset) F(IndexSize)
#define MGP_FIELDS_MGPIndirectBuffers(F) \
F(DrawIndirect) F(Parameter)
#define MGP_FIELDS_MGPBoundView(F) \
F(View) F(Texture) F(Unit)
#define MGP_FIELDS_MGPSamplerViews(F) \
F(Start) F(Count) F(ContentHash)
#define MGP_FIELDS_MGPSamplerStates(F) \
F(Start) F(Count) F(ContentHash)
#define MGP_FIELDS_MGPImageView(F) \
F(Res) F(Unit) F(InternalFormat) F(Layer) F(Level) F(Layered) F(Access)
#define MGP_FIELDS_MGPShaderImages(F) \
F(Start) F(Count) F(ContentHash)
#define MGP_FIELDS_MGPBufferRange(F) \
F(Res) F(Offset) F(Size)
#define MGP_FIELDS_MGPShaderBuffers(F) \
F(Class) F(Start) F(Count) F(WritableMask) F(HostSpanCount) F(ContentHash)
#define MGP_FIELDS_MGPStreamOutputTargets(F) \
F(Count) F(Generation) F(ContentHash)
#define MGP_FIELDS_MGPGlobalConstants(F) \
F(ShaderCso) F(Version) F(Blob)
#define MGP_FIELDS_MGPAttribValue(F) \
F(Location) F(ValueClass) F(Data)
#define MGP_FIELDS_MGPVertexAttribDefaults(F) \
F(Mask) F(Count)
#define MGP_FIELDS_MGPPixelPackState(F) \
F(Pack)
#define MGP_FIELDS_MGPPatchState(F) \
F(Vertices) F(Outer) F(Inner)
#define MGP_FIELDS_ResidualValueBlock(F) \
F(RenderState) F(Pack) F(CapabilityBits) F(PatchVertices) F(PatchOuter) F(PatchInner)
#define MGP_FIELDS_MGPResidualValueState(F) \
F(Version) F(Blob)
#define MGP_FIELDS_MGPSubRegion(F) \
F(X) F(Y) F(Z) F(W) F(H) F(D) F(SrcOffset) F(SrcRowStride) F(SrcSliceStride)
#define MGP_FIELDS_MGPSubData(F) \
F(Res) F(Target) F(Level) F(SourceIsVerbatimLevelShadow) F(UnionBox) F(RegionCount) F(Blob)
#define MGP_FIELDS_MGPSubDataComplete(F) \
F(Res) F(Target) F(FirstLevel) F(LevelCount) F(PullSerial)
#define MGP_FIELDS_MGPFlushRange(F) \
F(Res) F(Offset) F(Size) F(AccessFlags)
#define MGP_FIELDS_MGPReadback(F) \
F(Res) F(Offset) F(Size)
#define MGP_FIELDS_MGPCopyRegion(F) \
F(Src) F(Dst) F(SrcBox) F(DstX) F(DstY) F(DstZ) F(SrcTarget) F(DstTarget) F(SrcLevel) F(DstLevel)
#define MGP_FIELDS_MGPBlit(F) \
F(ReadFbo) F(DrawFbo) F(SrcX0) F(SrcY0) F(SrcX1) F(SrcY1) F(DstX0) F(DstY0) F(DstX1) F(DstY1) \
F(Mask) F(Filter)
#define MGP_FIELDS_MGPClear(F) \
F(Fbo) F(Kind) F(DrawBufferIndex) F(BufferMask) F(ValueClass) F(ColorValue) F(DepthValue) \
F(StencilValue)
#define MGP_FIELDS_MGPMipPlan(F) \
F(Res) F(Target) F(BaseLevel) F(LevelCount)
#define MGP_FIELDS_MGPReadbackInfo(F) \
F(Res) F(Box) F(Format) F(Type) F(Target) F(Level) F(DstOffset) F(DstSize)
#define MGP_FIELDS_MGPDrawInfo(F) \
F(Mode) F(IndexSize) F(Flags) F(InstanceCount) F(StartInstance) F(RestartIndex) F(DrawIdOffset) \
F(IndexResource) F(MinIndex) F(MaxIndex) F(XfbCpuCapturedVertices) F(NumDraws)
#define MGP_FIELDS_MGPDrawRange(F) \
F(Start) F(Count) F(IndexBias)
#define MGP_FIELDS_MGPDrawIndirect(F) \
F(Buffer) F(ParameterBuffer) F(Offset) F(ParameterOffset) F(Stride) F(DrawCount)
#define MGP_FIELDS_MGPGridInfo(F) \
F(GridX) F(GridY) F(GridZ) F(BlockX) F(BlockY) F(BlockZ) F(IndirectBuffer) F(IndirectOffset) \
F(IsIndirect)
#define MGP_FIELDS_MGPMemoryBarrier(F) \
F(Bits) F(ByRegion)
#define MGP_FIELDS_MGPStreamOutputBegin(F) \
F(PrimitiveMode)
#define MGP_FIELDS_MGPXfbAccounting(F) \
F(CapturedVertices) F(PrimitivesWritten) F(PrimitiveMode)
#define MGP_FIELDS_MGPStreamOutputControl(F) \
F(Reserved)
#define MGP_FIELDS_MGPFlush(F) \
F(Flags)
#define MGP_FIELDS_MGPPresent(F) \
F(FrameSerial)
#define MGP_FIELDS_MGPSwapInterval(F) \
F(Interval)
#define MGP_FIELDS_MGPSurfaceInfo(F) \
F(Width) F(Height) F(InternalFormat) F(Samples) F(Layers) F(IsDefault)
// Every payload above, in the order the comparator is generated. Keep in sync with the
// macros; gen_pipe.py reads THIS list to know what to emit.
#define MGP_VERIFY_PAYLOAD_LIST(P) \
P(MGPBlobRef) P(MGPRange) P(MGPBox) P(MGPReplySlot) P(MGPStateChunk) P(MGPHandleOnly) P(MGPCaps) \
P(MGPResourceDesc) P(MGPFenceWait) P(MGPQueryDesc) P(MGPQueryResultRequest) P(MGPTimestampRequest) P(MGPRenderStateDesc) \
P(MGPBindRenderState) P(MGPDynamicState) P(MGPVertexElements) P(MGPSamplerDesc) P(MGPSamplerView) \
P(MGPTextureParams) P(MGPProgramDesc) P(MGPSurface) P(MGPFramebufferState) P(MGPVertexBuffer) \
P(MGPVertexBuffers) P(MGPIndexBuffer) P(MGPIndirectBuffers) P(MGPBoundView) P(MGPSamplerViews) \
P(MGPSamplerStates) P(MGPImageView) P(MGPShaderImages) P(MGPBufferRange) P(MGPShaderBuffers) \
P(MGPStreamOutputTargets) P(MGPGlobalConstants) P(MGPAttribValue) P(MGPVertexAttribDefaults) \
P(MGPPixelPackState) P(MGPPatchState) P(ResidualValueBlock) P(MGPResidualValueState) \
P(MGPSubRegion) P(MGPSubData) P(MGPSubDataComplete) P(MGPFlushRange) P(MGPReadback) \
P(MGPCopyRegion) P(MGPBlit) P(MGPClear) P(MGPMipPlan) P(MGPReadbackInfo) P(MGPDrawInfo) \
P(MGPDrawRange) P(MGPDrawIndirect) P(MGPGridInfo) P(MGPMemoryBarrier) P(MGPStreamOutputBegin) \
P(MGPXfbAccounting) P(MGPStreamOutputControl) P(MGPFlush) P(MGPPresent) P(MGPSwapInterval) \
P(MGPSurfaceInfo)
// clang-format on
+108
View File
@@ -0,0 +1,108 @@
// MobileGL - MobileGL/MG_Pipe/generated/PipeCoverage.inc
// 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
// G6: backend read inventory -> MGPipe call coverage.
//
// GENERATED by scripts/gen_pipe.py from Coverage.def and scripts/data/backend_read_inventory.md - DO NOT EDIT.
// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result.
// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe.
// The acceptance rule (plan B section 10.3-5): regenerate, `git diff --exit-code`, and
// ZERO unmapped rows. P0 permits unmapped rows and only counts them; the count below is
// the number the later gate has to drive to zero.
//
// Three pseudo-calls stand for read points that never become a forward record:
// kClientResolved (the frontend answers it), kReverseChannel (it becomes one of the ten
// MGPipeCallbacks) and kStructuralHandle (the row is a signature carrying a
// SharedPtr<MG_State...> that becomes an MGPipeHandle parameter).
struct MGPipeCoverageEntry {
const char* Accessor;
const char* Call;
Uint32 ReadPoints;
};
inline constexpr MGPipeCoverageEntry kMGPipeCoverage[] = {
{"Buffer ops delta", "ResourceRespecify", 17},
{"GetActiveTextureUnit", "SetSamplerViews", 8},
{"GetBlendColor", "SetDynamicState", 1},
{"GetBlendEquationIndexed", "CreateRenderState", 1},
{"GetBlendFuncIndexed", "CreateRenderState", 1},
{"GetBoundTransformFeedbackName", "SetStreamOutputTargets", 1},
{"GetBoundVertexArray", "BindVertexElements", 12},
{"GetBufferBindingPoint", "SetShaderBuffers", 19},
{"GetBufferBindingPointCount", "SetShaderBuffers", 3},
{"GetBufferBindingSlot", "SetIndirectBuffers", 29},
{"GetClampReadColor", "SetDynamicState", 1},
{"GetClearColor", "SetDynamicState", 1},
{"GetClearDepth", "SetDynamicState", 1},
{"GetClearStencil", "SetDynamicState", 1},
{"GetColorMaskIndexed", "CreateRenderState", 6},
{"GetCullFaceMode", "CreateRenderState", 1},
{"GetCurrentVertexAttribute", "SetVertexAttribDefaults", 2},
{"GetDepthFunc", "CreateRenderState", 1},
{"GetDepthMask", "CreateRenderState", 5},
{"GetDepthRangeIndexed", "SetDynamicState", 1},
{"GetFramebufferBindingSlot", "SetFramebufferState", 19},
{"GetImageTextureBinding", "SetShaderImages", 14},
{"GetLineWidth", "SetDynamicState", 1},
{"GetLogicOp", "CreateRenderState", 1},
{"GetMaxTouchedTextureUnit", "SetSamplerViews", 1},
{"GetMinSampleShadingValue", "CreateRenderState", 1},
{"GetPatchDefaultInnerLevel", "SetPatchState", 3},
{"GetPatchDefaultOuterLevel", "SetPatchState", 3},
{"GetPatchVertices", "SetPatchState", 3},
{"GetPipelineStateVersion", "BindRenderState", 3},
{"GetPixelStoreParameters", "SetPixelPackState", 6},
{"GetPolygonModeFront", "CreateRenderState", 1},
{"GetPolygonOffsetFactor", "SetDynamicState", 1},
{"GetPolygonOffsetUnits", "SetDynamicState", 1},
{"GetPrimitiveRestartIndex", "DrawVbo", 3},
{"GetProgramForDispatch", "SetDispatchProgram", 3},
{"GetProgramForDraw", "SetDrawProgram", 7},
{"GetProgramObject", "CreateShaderState", 3},
{"GetProvokingVertexMode", "CreateRenderState", 1},
{"GetRenderStateParameters", "CreateRenderState", 11},
{"GetRenderStateParametersVersion", "BindRenderState", 2},
{"GetSamplingResolutionGeneration", "SetSamplerViews", 9},
{"GetScissorBox", "SetDynamicState", 3},
{"GetStencilState", "CreateRenderState", 8},
{"GetTextureBindGeneration", "SetSamplerViews", 5},
{"GetTextureContextId", "SetSamplerViews", 6},
{"GetTextureObject", "SetSamplerViews", 1},
{"GetTextureUnitObject", "SetSamplerViews", 19},
{"GetTouchedBufferBindingPointCount", "SetShaderBuffers", 2},
{"GetTransformFeedbackCapturedVertices", "DrawVbo", 1},
{"GetTransformFeedbackGeneration", "SetStreamOutputTargets", 1},
{"GetTransformFeedbackPausedPrimitiveCounter", "EndStreamOutput", 2},
{"GetTransformFeedbackProgram", "SetStreamOutputTargets", 3},
{"GetViewport", "SetDynamicState", 1},
{"GetViewportIndexed", "SetDynamicState", 1},
{"InvalidateCompileEnv", "kClientResolved", 2},
{"IsCapabilityEnabled", "CreateRenderState", 29},
{"IsCapabilityEnabledIndexed", "CreateRenderState", 1},
{"IsTransformFeedbackActive", "BeginStreamOutput", 5},
{"IsTransformFeedbackPaused", "PauseStreamOutput", 2},
{"RecordError", "kReverseChannel", 6},
{"ValidateProgramName", "kClientResolved", 3},
{"handle-ify (wire handle)", "kStructuralHandle", 167},
};
inline constexpr SizeT kMGPipeCoverageEntryCount = 63;
inline constexpr Uint32 kMGPipeInventoryReadPoints = 477;
inline constexpr Uint32 kMGPipeInventoryMappedToCall = 299;
inline constexpr Uint32 kMGPipeInventoryClientResolved = 5;
inline constexpr Uint32 kMGPipeInventoryReverseChannel = 6;
inline constexpr Uint32 kMGPipeInventoryStructuralHandle = 167;
inline constexpr Uint32 kMGPipeInventoryUnmapped = 0;
static_assert(kMGPipeCoverageEntryCount == sizeof(kMGPipeCoverage) / sizeof(kMGPipeCoverage[0]));
static_assert(kMGPipeInventoryMappedToCall + kMGPipeInventoryClientResolved +
kMGPipeInventoryReverseChannel + kMGPipeInventoryStructuralHandle +
kMGPipeInventoryUnmapped ==
kMGPipeInventoryReadPoints,
"every inventory row must land in exactly one bucket");
+308
View File
@@ -0,0 +1,308 @@
// MobileGL - MobileGL/MG_Pipe/generated/PipeFilled.inc
// 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
// G5: PipeInputs field ids and the per-verb poison generations.
//
// GENERATED by scripts/gen_pipe.py from Coverage.def and PipeCalls.def - DO NOT EDIT.
// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result.
// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe.
// One field id per GLContext accessor the backends actually read (plan B section 6.2:
// PipeInputs is organized by MEMO KEY, not by read point, which is why the field set is
// small and stable across the whole migration).
//
// The poison is a per-verb GENERATION, not a bit. A bitmap cannot see the dangerous case:
// a field filled by the previous DRAW and then read by the glTexSubImage that follows is
// stale, and its bit is already set. So every verb bumps CurrentVerbSerial, filling a
// field stamps it with that serial, and reading a non-sticky field whose stamp is older is
// Fatal{UnmigratedPipeInput} (section 6.2.2).
//
// P0 is the skeleton: the enum, the tables and the assertion helper exist, PipeInputs
// itself lands in P1.
enum class MGPipeInputField : Uint16 {
GetActiveTextureUnit,
GetBlendColor,
GetBlendEquationIndexed,
GetBlendFuncIndexed,
GetBoundTransformFeedbackName,
GetBoundVertexArray,
GetBufferBindingSlot,
GetBufferBindingPoint,
GetBufferBindingPointCount,
GetTouchedBufferBindingPointCount,
GetClampReadColor,
GetClearColor,
GetClearDepth,
GetClearStencil,
GetColorMaskIndexed,
GetCullFaceMode,
GetCurrentVertexAttribute,
GetDepthFunc,
GetDepthMask,
GetDepthRangeIndexed,
GetFramebufferBindingSlot,
GetImageTextureBinding,
GetLineWidth,
GetLogicOp,
GetMaxTouchedTextureUnit,
GetMinSampleShadingValue,
GetPatchDefaultInnerLevel,
GetPatchDefaultOuterLevel,
GetPatchVertices,
GetPipelineStateVersion,
GetPixelStoreParameters,
GetPolygonModeFront,
GetPolygonOffsetFactor,
GetPolygonOffsetUnits,
GetPrimitiveRestartIndex,
GetProgramForDispatch,
GetProgramForDraw,
GetProgramObject,
GetProvokingVertexMode,
GetRenderStateParameters,
GetRenderStateParametersVersion,
GetSamplingResolutionGeneration,
GetScissorBox,
GetStencilState,
GetTextureBindGeneration,
GetTextureContextId,
GetTextureObject,
GetTextureUnitObject,
GetTransformFeedbackCapturedVertices,
GetTransformFeedbackGeneration,
GetTransformFeedbackPausedPrimitiveCounter,
GetTransformFeedbackProgram,
GetViewport,
GetViewportIndexed,
IsCapabilityEnabled,
IsCapabilityEnabledIndexed,
IsTransformFeedbackActive,
IsTransformFeedbackPaused,
InvalidateCompileEnv,
ValidateProgramName,
RecordError,
kFieldCount,
};
inline constexpr SizeT kMGPipeInputFieldCount = static_cast<SizeT>(MGPipeInputField::kFieldCount);
static_assert(kMGPipeInputFieldCount == 61, "the PipeInputs field set moved");
inline constexpr const char* kMGPipeInputFieldNames[kMGPipeInputFieldCount] = {
"GetActiveTextureUnit",
"GetBlendColor",
"GetBlendEquationIndexed",
"GetBlendFuncIndexed",
"GetBoundTransformFeedbackName",
"GetBoundVertexArray",
"GetBufferBindingSlot",
"GetBufferBindingPoint",
"GetBufferBindingPointCount",
"GetTouchedBufferBindingPointCount",
"GetClampReadColor",
"GetClearColor",
"GetClearDepth",
"GetClearStencil",
"GetColorMaskIndexed",
"GetCullFaceMode",
"GetCurrentVertexAttribute",
"GetDepthFunc",
"GetDepthMask",
"GetDepthRangeIndexed",
"GetFramebufferBindingSlot",
"GetImageTextureBinding",
"GetLineWidth",
"GetLogicOp",
"GetMaxTouchedTextureUnit",
"GetMinSampleShadingValue",
"GetPatchDefaultInnerLevel",
"GetPatchDefaultOuterLevel",
"GetPatchVertices",
"GetPipelineStateVersion",
"GetPixelStoreParameters",
"GetPolygonModeFront",
"GetPolygonOffsetFactor",
"GetPolygonOffsetUnits",
"GetPrimitiveRestartIndex",
"GetProgramForDispatch",
"GetProgramForDraw",
"GetProgramObject",
"GetProvokingVertexMode",
"GetRenderStateParameters",
"GetRenderStateParametersVersion",
"GetSamplingResolutionGeneration",
"GetScissorBox",
"GetStencilState",
"GetTextureBindGeneration",
"GetTextureContextId",
"GetTextureObject",
"GetTextureUnitObject",
"GetTransformFeedbackCapturedVertices",
"GetTransformFeedbackGeneration",
"GetTransformFeedbackPausedPrimitiveCounter",
"GetTransformFeedbackProgram",
"GetViewport",
"GetViewportIndexed",
"IsCapabilityEnabled",
"IsCapabilityEnabledIndexed",
"IsTransformFeedbackActive",
"IsTransformFeedbackPaused",
"InvalidateCompileEnv",
"ValidateProgramName",
"RecordError",
};
// Fields whose value is valid ACROSS verbs. Every entry is false in P0 and each
// true has to be argued for in P1 when the fillers land: a sticky field is a field
// the poison cannot protect.
inline constexpr Bool kMGPipeInputFieldSticky[kMGPipeInputFieldCount] = {
false, // GetActiveTextureUnit
false, // GetBlendColor
false, // GetBlendEquationIndexed
false, // GetBlendFuncIndexed
false, // GetBoundTransformFeedbackName
false, // GetBoundVertexArray
false, // GetBufferBindingSlot
false, // GetBufferBindingPoint
false, // GetBufferBindingPointCount
false, // GetTouchedBufferBindingPointCount
false, // GetClampReadColor
false, // GetClearColor
false, // GetClearDepth
false, // GetClearStencil
false, // GetColorMaskIndexed
false, // GetCullFaceMode
false, // GetCurrentVertexAttribute
false, // GetDepthFunc
false, // GetDepthMask
false, // GetDepthRangeIndexed
false, // GetFramebufferBindingSlot
false, // GetImageTextureBinding
false, // GetLineWidth
false, // GetLogicOp
false, // GetMaxTouchedTextureUnit
false, // GetMinSampleShadingValue
false, // GetPatchDefaultInnerLevel
false, // GetPatchDefaultOuterLevel
false, // GetPatchVertices
false, // GetPipelineStateVersion
false, // GetPixelStoreParameters
false, // GetPolygonModeFront
false, // GetPolygonOffsetFactor
false, // GetPolygonOffsetUnits
false, // GetPrimitiveRestartIndex
false, // GetProgramForDispatch
false, // GetProgramForDraw
false, // GetProgramObject
false, // GetProvokingVertexMode
false, // GetRenderStateParameters
false, // GetRenderStateParametersVersion
false, // GetSamplingResolutionGeneration
false, // GetScissorBox
false, // GetStencilState
false, // GetTextureBindGeneration
false, // GetTextureContextId
false, // GetTextureObject
false, // GetTextureUnitObject
false, // GetTransformFeedbackCapturedVertices
false, // GetTransformFeedbackGeneration
false, // GetTransformFeedbackPausedPrimitiveCounter
false, // GetTransformFeedbackProgram
false, // GetViewport
false, // GetViewportIndexed
false, // IsCapabilityEnabled
false, // IsCapabilityEnabledIndexed
false, // IsTransformFeedbackActive
false, // IsTransformFeedbackPaused
false, // InvalidateCompileEnv
false, // ValidateProgramName
false, // RecordError
};
// Which call is expected to have filled a field by the time a verb reads it. Names
// come from Coverage.def, so this table and the coverage table cannot disagree.
inline constexpr const char* kMGPipeInputFieldFilledBy[kMGPipeInputFieldCount] = {
"SetSamplerViews",
"SetDynamicState",
"CreateRenderState",
"CreateRenderState",
"SetStreamOutputTargets",
"BindVertexElements",
"SetIndirectBuffers",
"SetShaderBuffers",
"SetShaderBuffers",
"SetShaderBuffers",
"SetDynamicState",
"SetDynamicState",
"SetDynamicState",
"SetDynamicState",
"CreateRenderState",
"CreateRenderState",
"SetVertexAttribDefaults",
"CreateRenderState",
"CreateRenderState",
"SetDynamicState",
"SetFramebufferState",
"SetShaderImages",
"SetDynamicState",
"CreateRenderState",
"SetSamplerViews",
"CreateRenderState",
"SetPatchState",
"SetPatchState",
"SetPatchState",
"BindRenderState",
"SetPixelPackState",
"CreateRenderState",
"SetDynamicState",
"SetDynamicState",
"DrawVbo",
"SetDispatchProgram",
"SetDrawProgram",
"CreateShaderState",
"CreateRenderState",
"CreateRenderState",
"BindRenderState",
"SetSamplerViews",
"SetDynamicState",
"CreateRenderState",
"SetSamplerViews",
"SetSamplerViews",
"SetSamplerViews",
"SetSamplerViews",
"DrawVbo",
"SetStreamOutputTargets",
"EndStreamOutput",
"SetStreamOutputTargets",
"SetDynamicState",
"SetDynamicState",
"CreateRenderState",
"CreateRenderState",
"BeginStreamOutput",
"PauseStreamOutput",
"kClientResolved", // pseudo-call: not filled by a forward record
"kClientResolved", // pseudo-call: not filled by a forward record
"kReverseChannel", // pseudo-call: not filled by a forward record
};
struct MGPipeFilledState {
Uint64 CurrentVerbSerial;
Uint64 FilledGen[kMGPipeInputFieldCount];
};
[[noreturn]] inline void MGPipeInputPoisonFatal(MGPipeInputField field, const char* verb) {
MGLOG_F("MGPipe: Fatal{UnmigratedPipeInput, \"%s@%s\"}",
kMGPipeInputFieldNames[static_cast<SizeT>(field)], verb);
std::abort();
}
inline Bool MGPipeInputFieldIsFresh(const MGPipeFilledState& state, MGPipeInputField field) {
const SizeT index = static_cast<SizeT>(field);
return kMGPipeInputFieldSticky[index] ? state.FilledGen[index] != 0
: state.FilledGen[index] == state.CurrentVerbSerial;
}
@@ -0,0 +1,67 @@
// MobileGL - MobileGL/MG_Pipe/generated/PipeSpanTable.inc
// 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
// G7: the render-state pipeline subset, by member name.
//
// GENERATED by scripts/gen_pipe.py from the field list in scripts/gen_pipe.py - DO NOT EDIT.
// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result.
// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe.
// D-B1 rejected three CSOs and demanded this table instead, so the table needs its own
// completeness trip wire: MG_Test walks every public RenderState setter and asserts that
// the pipeline-subset hash moves IF AND ONLY IF m_pipelineStateVersion moves. That test
// and MGPipeRenderStateSpans.cpp land with P2; what P0 pins is the MEMBER LIST, taken from
// what VulkanRenderer::ComputePipelineStateHash hashes today, so the later offsets are
// derived from a list that was reviewed rather than invented.
//
// Deliberately absent, and each absence is a question P2 has to answer before the chunk
// table freezes:
// - FramebufferSrgb and DepthClamp have NO STORAGE at all (RenderState.cpp's SetCapability
// falls to "not supported currently" and IsCapabilityEnabled returns false), so six
// backend read points are constant false today. Pipeline state or dead capability?
// - ProvokingVertexModeSetting is Vulkan pipeline state but is not hashed today.
// - FrontFaceModeSetting, ClipOrigin and ClipDepthMode are pipeline state on Vulkan and
// are handled elsewhere in the payload path rather than in the memo word.
//
// The complement of this list is the DYNAMIC subset - the half whose whole purpose is that
// glViewport must not mint a new CSO.
inline constexpr const char* const kMGPipePipelineStateMembers[] = {
"CullFaceEnabled",
"DepthTestEnabled",
"PolygonOffsetFillEnabled",
"RasterizerDiscardEnabled",
"ColorLogicOpEnabled",
"StencilTestEnabled",
"PrimitiveRestartEnabled",
"PrimitiveRestartFixedIndexEnabled",
"DepthMask",
"SampleShadingEnabled",
"MultisampleEnabled",
"SampleMaskEnabled",
"SampleMaskValue",
"MinSampleShadingValue",
"PatchVertices",
"PatchDefaultOuterLevel",
"PatchDefaultInnerLevel",
"PolygonModeFront",
"CullFaceModeSetting",
"DepthFunc",
"LogicOp",
"StencilStates",
"BlendStates",
"ColorMasks",
};
inline constexpr SizeT kMGPipePipelineStateMemberCount = 24;
static_assert(kMGPipePipelineStateMemberCount ==
sizeof(kMGPipePipelineStateMembers) / sizeof(kMGPipePipelineStateMembers[0]));
// Filled in by MG_Pipe/MGPipeRenderStateSpans.cpp (P2), which computes the offsets
// in C++ with offsetof rather than guessing them in python.
extern const MGPStateChunk kMGPipePipelineChunks[];
extern const MGPStateChunk kMGPipeDynamicChunks[];
+108
View File
@@ -0,0 +1,108 @@
// MobileGL - MobileGL/MG_Pipe/generated/PipeTables.inc
// 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
// G1: the two MGPipe interface tables.
//
// GENERATED by scripts/gen_pipe.py from PipeCalls.def - DO NOT EDIT.
// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result.
// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe.
// share group: 11 calls. A null entry means the backend does not implement this
// call and the frontend keeps its own path (plan B section 4.1).
struct MGPipeScreen {
void (*GetCaps)(const MGPCaps* payload, MGPReplySlot* reply);
void (*ResourceCreate)(const MGPResourceDesc* payload);
void (*ResourceRespecify)(const MGPResourceDesc* payload);
void (*ResourceDestroy)(const MGPHandleOnly* payload);
void (*MapPersistent)(const MGPHandleOnly* payload, MGPReplySlot* reply);
void (*UnmapPersistent)(const MGPHandleOnly* payload);
void (*FenceCreate)(const MGPHandleOnly* payload);
void (*FenceStatus)(const MGPHandleOnly* payload, MGPReplySlot* reply);
void (*FenceWait)(const MGPFenceWait* payload, MGPReplySlot* reply);
void (*FenceDestroy)(const MGPHandleOnly* payload);
void (*FenceWaitServer)(const MGPFenceWait* payload);
};
// context: 60 calls. A null entry means the backend does not implement this
// call and the frontend keeps its own path (plan B section 4.1).
struct MGPipeContext {
void (*QueryCreate)(const MGPQueryDesc* payload);
void (*QueryBegin)(const MGPQueryDesc* payload);
void (*QueryEnd)(const MGPQueryDesc* payload);
void (*QueryAvailable)(const MGPHandleOnly* payload, MGPReplySlot* reply);
void (*QueryResult)(const MGPQueryResultRequest* payload, MGPReplySlot* reply);
void (*QueryDestroy)(const MGPHandleOnly* payload);
void (*CreateRenderState)(const MGPRenderStateDesc* payload);
void (*BindRenderState)(const MGPBindRenderState* payload);
void (*DeleteRenderState)(const MGPHandleOnly* payload);
void (*CreateVertexElements)(const MGPVertexElements* payload);
void (*BindVertexElements)(const MGPHandleOnly* payload);
void (*DeleteVertexElements)(const MGPHandleOnly* payload);
void (*CreateSamplerState)(const MGPSamplerDesc* payload);
void (*DeleteSamplerState)(const MGPHandleOnly* payload);
void (*CreateSamplerView)(const MGPSamplerView* payload);
void (*DeleteSamplerView)(const MGPHandleOnly* payload);
void (*CreateShaderState)(const MGPProgramDesc* payload);
void (*BindShaderState)(const MGPHandleOnly* payload);
void (*DeleteShaderState)(const MGPHandleOnly* payload);
void (*SetDynamicState)(const MGPDynamicState* payload);
void (*SetFramebufferState)(const MGPFramebufferState* payload);
void (*SetVertexBuffers)(const MGPVertexBuffers* payload, const void* varTail, Uint32 varTailCount);
void (*SetIndexBuffer)(const MGPIndexBuffer* payload);
void (*SetIndirectBuffers)(const MGPIndirectBuffers* payload);
void (*SetSamplerViews)(const MGPSamplerViews* payload, const void* varTail, Uint32 varTailCount);
void (*BindSamplerStates)(const MGPSamplerStates* payload, const void* varTail, Uint32 varTailCount);
void (*SetShaderImages)(const MGPShaderImages* payload, const void* varTail, Uint32 varTailCount);
void (*SetShaderBuffers)(const MGPShaderBuffers* payload, const void* varTail, Uint32 varTailCount);
void (*SetStreamOutputTargets)(const MGPStreamOutputTargets* payload, const void* varTail, Uint32 varTailCount);
void (*SetGlobalConstants)(const MGPGlobalConstants* payload);
void (*SetVertexAttribDefaults)(const MGPVertexAttribDefaults* payload, const void* varTail, Uint32 varTailCount);
void (*SetPixelPackState)(const MGPPixelPackState* payload);
void (*SetPatchState)(const MGPPatchState* payload);
void (*SetDrawProgram)(const MGPHandleOnly* payload);
void (*SetDispatchProgram)(const MGPHandleOnly* payload);
void (*SetResidualValueState)(const MGPResidualValueState* payload);
void (*SetTextureParams)(const MGPTextureParams* payload);
void (*ResourceSubData)(const MGPSubData* payload, const void* varTail, Uint32 varTailCount);
void (*BufferSubDataResident)(const MGPSubData* payload);
void (*ResourceSubDataComplete)(const MGPSubDataComplete* payload);
void (*ResourceFlushRange)(const MGPFlushRange* payload);
void (*ResourceReadback)(const MGPReadback* payload, MGPReplySlot* reply);
void (*ResourceCopyRegion)(const MGPCopyRegion* payload);
void (*GenerateMipmap)(const MGPMipPlan* payload);
void (*GetTextureImage)(const MGPReadbackInfo* payload, MGPReplySlot* reply);
void (*Blit)(const MGPBlit* payload);
void (*Clear)(const MGPClear* payload);
void (*ReadPixels)(const MGPReadbackInfo* payload, MGPReplySlot* reply);
void (*DrawVbo)(const MGPDrawInfo* payload, const void* varTail, Uint32 varTailCount);
void (*LaunchGrid)(const MGPGridInfo* payload);
void (*MemoryBarrier)(const MGPMemoryBarrier* payload);
void (*BeginStreamOutput)(const MGPStreamOutputBegin* payload);
void (*EndStreamOutput)(const MGPXfbAccounting* payload);
void (*PauseStreamOutput)(const MGPStreamOutputControl* payload);
void (*ResumeStreamOutput)(const MGPStreamOutputControl* payload);
void (*Flush)(const MGPFlush* payload);
void (*Present)(const MGPPresent* payload);
void (*SetSwapInterval)(const MGPSwapInterval* payload);
void (*QueryTimestamp)(const MGPTimestampRequest* payload, MGPReplySlot* reply);
void (*QueryCounter)(const MGPQueryDesc* payload);
};
inline constexpr SizeT kMGPipeScreenCallCount = 11;
inline constexpr SizeT kMGPipeContextCallCount = 60;
inline constexpr SizeT kMGPipeCallCount = 71;
// A table that is not exactly its call count of function pointers has grown a
// member that no generator knows about.
static_assert(sizeof(MGPipeScreen) == kMGPipeScreenCallCount * sizeof(void (*)()),
"MGPipeScreen is not exactly its catalogue's function pointers");
static_assert(sizeof(MGPipeContext) == kMGPipeContextCallCount * sizeof(void (*)()),
"MGPipeContext is not exactly its catalogue's function pointers");
static_assert(kMGPipeScreenCallCount + kMGPipeContextCallCount == kMGPipeCallCount);
static_assert(kMGPipeCallCount == MGP_CALL_LIST_DOCUMENTED_COUNT,
"the catalogue and its documented count disagree");
+302
View File
@@ -0,0 +1,302 @@
// MobileGL - MobileGL/MG_Pipe/generated/PipeThunks.inc
// 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
// G2: monolith thunks over the two tables.
//
// GENERATED by scripts/gen_pipe.py from PipeCalls.def - DO NOT EDIT.
// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result.
// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe.
// One inline call through the installed table. These are the names MG_Impl call
// sites move onto, replacing gBackendFunctionsTable.GL.* one at a time. An
// unimplemented (null) entry is the caller's business to check, exactly as it is
// with the table this replaces.
inline void MGP_GetCaps(const MGPCaps* payload, MGPReplySlot* reply) {
gMGPipeScreen.GetCaps(payload, reply);
}
inline void MGP_ResourceCreate(const MGPResourceDesc* payload) {
gMGPipeScreen.ResourceCreate(payload);
}
inline void MGP_ResourceRespecify(const MGPResourceDesc* payload) {
gMGPipeScreen.ResourceRespecify(payload);
}
inline void MGP_ResourceDestroy(const MGPHandleOnly* payload) {
gMGPipeScreen.ResourceDestroy(payload);
}
inline void MGP_MapPersistent(const MGPHandleOnly* payload, MGPReplySlot* reply) {
gMGPipeScreen.MapPersistent(payload, reply);
}
inline void MGP_UnmapPersistent(const MGPHandleOnly* payload) {
gMGPipeScreen.UnmapPersistent(payload);
}
inline void MGP_FenceCreate(const MGPHandleOnly* payload) {
gMGPipeScreen.FenceCreate(payload);
}
inline void MGP_FenceStatus(const MGPHandleOnly* payload, MGPReplySlot* reply) {
gMGPipeScreen.FenceStatus(payload, reply);
}
inline void MGP_FenceWait(const MGPFenceWait* payload, MGPReplySlot* reply) {
gMGPipeScreen.FenceWait(payload, reply);
}
inline void MGP_FenceDestroy(const MGPHandleOnly* payload) {
gMGPipeScreen.FenceDestroy(payload);
}
inline void MGP_QueryCreate(const MGPQueryDesc* payload) {
gMGPipeContext.QueryCreate(payload);
}
inline void MGP_QueryBegin(const MGPQueryDesc* payload) {
gMGPipeContext.QueryBegin(payload);
}
inline void MGP_QueryEnd(const MGPQueryDesc* payload) {
gMGPipeContext.QueryEnd(payload);
}
inline void MGP_QueryAvailable(const MGPHandleOnly* payload, MGPReplySlot* reply) {
gMGPipeContext.QueryAvailable(payload, reply);
}
inline void MGP_QueryResult(const MGPQueryResultRequest* payload, MGPReplySlot* reply) {
gMGPipeContext.QueryResult(payload, reply);
}
inline void MGP_QueryDestroy(const MGPHandleOnly* payload) {
gMGPipeContext.QueryDestroy(payload);
}
inline void MGP_CreateRenderState(const MGPRenderStateDesc* payload) {
gMGPipeContext.CreateRenderState(payload);
}
inline void MGP_BindRenderState(const MGPBindRenderState* payload) {
gMGPipeContext.BindRenderState(payload);
}
inline void MGP_DeleteRenderState(const MGPHandleOnly* payload) {
gMGPipeContext.DeleteRenderState(payload);
}
inline void MGP_CreateVertexElements(const MGPVertexElements* payload) {
gMGPipeContext.CreateVertexElements(payload);
}
inline void MGP_BindVertexElements(const MGPHandleOnly* payload) {
gMGPipeContext.BindVertexElements(payload);
}
inline void MGP_DeleteVertexElements(const MGPHandleOnly* payload) {
gMGPipeContext.DeleteVertexElements(payload);
}
inline void MGP_CreateSamplerState(const MGPSamplerDesc* payload) {
gMGPipeContext.CreateSamplerState(payload);
}
inline void MGP_DeleteSamplerState(const MGPHandleOnly* payload) {
gMGPipeContext.DeleteSamplerState(payload);
}
inline void MGP_CreateSamplerView(const MGPSamplerView* payload) {
gMGPipeContext.CreateSamplerView(payload);
}
inline void MGP_DeleteSamplerView(const MGPHandleOnly* payload) {
gMGPipeContext.DeleteSamplerView(payload);
}
inline void MGP_CreateShaderState(const MGPProgramDesc* payload) {
gMGPipeContext.CreateShaderState(payload);
}
inline void MGP_BindShaderState(const MGPHandleOnly* payload) {
gMGPipeContext.BindShaderState(payload);
}
inline void MGP_DeleteShaderState(const MGPHandleOnly* payload) {
gMGPipeContext.DeleteShaderState(payload);
}
inline void MGP_SetDynamicState(const MGPDynamicState* payload) {
gMGPipeContext.SetDynamicState(payload);
}
inline void MGP_SetFramebufferState(const MGPFramebufferState* payload) {
gMGPipeContext.SetFramebufferState(payload);
}
inline void MGP_SetVertexBuffers(const MGPVertexBuffers* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.SetVertexBuffers(payload, varTail, varTailCount);
}
inline void MGP_SetIndexBuffer(const MGPIndexBuffer* payload) {
gMGPipeContext.SetIndexBuffer(payload);
}
inline void MGP_SetIndirectBuffers(const MGPIndirectBuffers* payload) {
gMGPipeContext.SetIndirectBuffers(payload);
}
inline void MGP_SetSamplerViews(const MGPSamplerViews* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.SetSamplerViews(payload, varTail, varTailCount);
}
inline void MGP_BindSamplerStates(const MGPSamplerStates* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.BindSamplerStates(payload, varTail, varTailCount);
}
inline void MGP_SetShaderImages(const MGPShaderImages* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.SetShaderImages(payload, varTail, varTailCount);
}
inline void MGP_SetShaderBuffers(const MGPShaderBuffers* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.SetShaderBuffers(payload, varTail, varTailCount);
}
inline void MGP_SetStreamOutputTargets(const MGPStreamOutputTargets* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.SetStreamOutputTargets(payload, varTail, varTailCount);
}
inline void MGP_SetGlobalConstants(const MGPGlobalConstants* payload) {
gMGPipeContext.SetGlobalConstants(payload);
}
inline void MGP_SetVertexAttribDefaults(const MGPVertexAttribDefaults* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.SetVertexAttribDefaults(payload, varTail, varTailCount);
}
inline void MGP_SetPixelPackState(const MGPPixelPackState* payload) {
gMGPipeContext.SetPixelPackState(payload);
}
inline void MGP_SetPatchState(const MGPPatchState* payload) {
gMGPipeContext.SetPatchState(payload);
}
inline void MGP_SetDrawProgram(const MGPHandleOnly* payload) {
gMGPipeContext.SetDrawProgram(payload);
}
inline void MGP_SetDispatchProgram(const MGPHandleOnly* payload) {
gMGPipeContext.SetDispatchProgram(payload);
}
inline void MGP_SetResidualValueState(const MGPResidualValueState* payload) {
gMGPipeContext.SetResidualValueState(payload);
}
inline void MGP_SetTextureParams(const MGPTextureParams* payload) {
gMGPipeContext.SetTextureParams(payload);
}
inline void MGP_ResourceSubData(const MGPSubData* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.ResourceSubData(payload, varTail, varTailCount);
}
inline void MGP_BufferSubDataResident(const MGPSubData* payload) {
gMGPipeContext.BufferSubDataResident(payload);
}
inline void MGP_ResourceSubDataComplete(const MGPSubDataComplete* payload) {
gMGPipeContext.ResourceSubDataComplete(payload);
}
inline void MGP_ResourceFlushRange(const MGPFlushRange* payload) {
gMGPipeContext.ResourceFlushRange(payload);
}
inline void MGP_ResourceReadback(const MGPReadback* payload, MGPReplySlot* reply) {
gMGPipeContext.ResourceReadback(payload, reply);
}
inline void MGP_ResourceCopyRegion(const MGPCopyRegion* payload) {
gMGPipeContext.ResourceCopyRegion(payload);
}
inline void MGP_GenerateMipmap(const MGPMipPlan* payload) {
gMGPipeContext.GenerateMipmap(payload);
}
inline void MGP_GetTextureImage(const MGPReadbackInfo* payload, MGPReplySlot* reply) {
gMGPipeContext.GetTextureImage(payload, reply);
}
inline void MGP_Blit(const MGPBlit* payload) {
gMGPipeContext.Blit(payload);
}
inline void MGP_Clear(const MGPClear* payload) {
gMGPipeContext.Clear(payload);
}
inline void MGP_ReadPixels(const MGPReadbackInfo* payload, MGPReplySlot* reply) {
gMGPipeContext.ReadPixels(payload, reply);
}
inline void MGP_DrawVbo(const MGPDrawInfo* payload, const void* varTail, Uint32 varTailCount) {
gMGPipeContext.DrawVbo(payload, varTail, varTailCount);
}
inline void MGP_LaunchGrid(const MGPGridInfo* payload) {
gMGPipeContext.LaunchGrid(payload);
}
inline void MGP_MemoryBarrier(const MGPMemoryBarrier* payload) {
gMGPipeContext.MemoryBarrier(payload);
}
inline void MGP_BeginStreamOutput(const MGPStreamOutputBegin* payload) {
gMGPipeContext.BeginStreamOutput(payload);
}
inline void MGP_EndStreamOutput(const MGPXfbAccounting* payload) {
gMGPipeContext.EndStreamOutput(payload);
}
inline void MGP_PauseStreamOutput(const MGPStreamOutputControl* payload) {
gMGPipeContext.PauseStreamOutput(payload);
}
inline void MGP_ResumeStreamOutput(const MGPStreamOutputControl* payload) {
gMGPipeContext.ResumeStreamOutput(payload);
}
inline void MGP_Flush(const MGPFlush* payload) {
gMGPipeContext.Flush(payload);
}
inline void MGP_Present(const MGPPresent* payload) {
gMGPipeContext.Present(payload);
}
inline void MGP_SetSwapInterval(const MGPSwapInterval* payload) {
gMGPipeContext.SetSwapInterval(payload);
}
inline void MGP_QueryTimestamp(const MGPTimestampRequest* payload, MGPReplySlot* reply) {
gMGPipeContext.QueryTimestamp(payload, reply);
}
inline void MGP_QueryCounter(const MGPQueryDesc* payload) {
gMGPipeContext.QueryCounter(payload);
}
inline void MGP_FenceWaitServer(const MGPFenceWait* payload) {
gMGPipeScreen.FenceWaitServer(payload);
}
+573
View File
@@ -0,0 +1,573 @@
// MobileGL - MobileGL/MG_Pipe/generated/PipeVerify.inc
// 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
// G4: the MOBILEGL_PIPE_VERIFY field-wise comparators.
//
// GENERATED by scripts/gen_pipe.py from PipeFields.def - DO NOT EDIT.
// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result.
// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe.
// Field by field, never memcmp over a whole payload: RenderStateParameters is documented
// in DirectGLES.cpp to false-DIFFER on padding under memcmp (harmlessly there, fatally
// here - a comparator with false positives is a comparator nobody reads). Each function
// reports the FIRST differing field by name, which with the draw serial is what the verify
// harness prints.
//
// Floating-point fields are compared by BITS, so a NaN patch level - which
// glPatchParameterfv accepts and ComputePipelineStateHash already hashes bitwise - equals
// itself instead of tripping every draw.
#include "../PipeFields.def"
template <class T>
struct MGPipeHasFieldVerifier : std::false_type {};
inline Bool MGPipeVerify(const MGPBlobRef& a, const MGPBlobRef& b, const char** outField);
inline Bool MGPipeVerify(const MGPRange& a, const MGPRange& b, const char** outField);
inline Bool MGPipeVerify(const MGPBox& a, const MGPBox& b, const char** outField);
inline Bool MGPipeVerify(const MGPReplySlot& a, const MGPReplySlot& b, const char** outField);
inline Bool MGPipeVerify(const MGPStateChunk& a, const MGPStateChunk& b, const char** outField);
inline Bool MGPipeVerify(const MGPHandleOnly& a, const MGPHandleOnly& b, const char** outField);
inline Bool MGPipeVerify(const MGPCaps& a, const MGPCaps& b, const char** outField);
inline Bool MGPipeVerify(const MGPResourceDesc& a, const MGPResourceDesc& b, const char** outField);
inline Bool MGPipeVerify(const MGPFenceWait& a, const MGPFenceWait& b, const char** outField);
inline Bool MGPipeVerify(const MGPQueryDesc& a, const MGPQueryDesc& b, const char** outField);
inline Bool MGPipeVerify(const MGPQueryResultRequest& a, const MGPQueryResultRequest& b, const char** outField);
inline Bool MGPipeVerify(const MGPTimestampRequest& a, const MGPTimestampRequest& b, const char** outField);
inline Bool MGPipeVerify(const MGPRenderStateDesc& a, const MGPRenderStateDesc& b, const char** outField);
inline Bool MGPipeVerify(const MGPBindRenderState& a, const MGPBindRenderState& b, const char** outField);
inline Bool MGPipeVerify(const MGPDynamicState& a, const MGPDynamicState& b, const char** outField);
inline Bool MGPipeVerify(const MGPVertexElements& a, const MGPVertexElements& b, const char** outField);
inline Bool MGPipeVerify(const MGPSamplerDesc& a, const MGPSamplerDesc& b, const char** outField);
inline Bool MGPipeVerify(const MGPSamplerView& a, const MGPSamplerView& b, const char** outField);
inline Bool MGPipeVerify(const MGPTextureParams& a, const MGPTextureParams& b, const char** outField);
inline Bool MGPipeVerify(const MGPProgramDesc& a, const MGPProgramDesc& b, const char** outField);
inline Bool MGPipeVerify(const MGPSurface& a, const MGPSurface& b, const char** outField);
inline Bool MGPipeVerify(const MGPFramebufferState& a, const MGPFramebufferState& b, const char** outField);
inline Bool MGPipeVerify(const MGPVertexBuffer& a, const MGPVertexBuffer& b, const char** outField);
inline Bool MGPipeVerify(const MGPVertexBuffers& a, const MGPVertexBuffers& b, const char** outField);
inline Bool MGPipeVerify(const MGPIndexBuffer& a, const MGPIndexBuffer& b, const char** outField);
inline Bool MGPipeVerify(const MGPIndirectBuffers& a, const MGPIndirectBuffers& b, const char** outField);
inline Bool MGPipeVerify(const MGPBoundView& a, const MGPBoundView& b, const char** outField);
inline Bool MGPipeVerify(const MGPSamplerViews& a, const MGPSamplerViews& b, const char** outField);
inline Bool MGPipeVerify(const MGPSamplerStates& a, const MGPSamplerStates& b, const char** outField);
inline Bool MGPipeVerify(const MGPImageView& a, const MGPImageView& b, const char** outField);
inline Bool MGPipeVerify(const MGPShaderImages& a, const MGPShaderImages& b, const char** outField);
inline Bool MGPipeVerify(const MGPBufferRange& a, const MGPBufferRange& b, const char** outField);
inline Bool MGPipeVerify(const MGPShaderBuffers& a, const MGPShaderBuffers& b, const char** outField);
inline Bool MGPipeVerify(const MGPStreamOutputTargets& a, const MGPStreamOutputTargets& b, const char** outField);
inline Bool MGPipeVerify(const MGPGlobalConstants& a, const MGPGlobalConstants& b, const char** outField);
inline Bool MGPipeVerify(const MGPAttribValue& a, const MGPAttribValue& b, const char** outField);
inline Bool MGPipeVerify(const MGPVertexAttribDefaults& a, const MGPVertexAttribDefaults& b, const char** outField);
inline Bool MGPipeVerify(const MGPPixelPackState& a, const MGPPixelPackState& b, const char** outField);
inline Bool MGPipeVerify(const MGPPatchState& a, const MGPPatchState& b, const char** outField);
inline Bool MGPipeVerify(const ResidualValueBlock& a, const ResidualValueBlock& b, const char** outField);
inline Bool MGPipeVerify(const MGPResidualValueState& a, const MGPResidualValueState& b, const char** outField);
inline Bool MGPipeVerify(const MGPSubRegion& a, const MGPSubRegion& b, const char** outField);
inline Bool MGPipeVerify(const MGPSubData& a, const MGPSubData& b, const char** outField);
inline Bool MGPipeVerify(const MGPSubDataComplete& a, const MGPSubDataComplete& b, const char** outField);
inline Bool MGPipeVerify(const MGPFlushRange& a, const MGPFlushRange& b, const char** outField);
inline Bool MGPipeVerify(const MGPReadback& a, const MGPReadback& b, const char** outField);
inline Bool MGPipeVerify(const MGPCopyRegion& a, const MGPCopyRegion& b, const char** outField);
inline Bool MGPipeVerify(const MGPBlit& a, const MGPBlit& b, const char** outField);
inline Bool MGPipeVerify(const MGPClear& a, const MGPClear& b, const char** outField);
inline Bool MGPipeVerify(const MGPMipPlan& a, const MGPMipPlan& b, const char** outField);
inline Bool MGPipeVerify(const MGPReadbackInfo& a, const MGPReadbackInfo& b, const char** outField);
inline Bool MGPipeVerify(const MGPDrawInfo& a, const MGPDrawInfo& b, const char** outField);
inline Bool MGPipeVerify(const MGPDrawRange& a, const MGPDrawRange& b, const char** outField);
inline Bool MGPipeVerify(const MGPDrawIndirect& a, const MGPDrawIndirect& b, const char** outField);
inline Bool MGPipeVerify(const MGPGridInfo& a, const MGPGridInfo& b, const char** outField);
inline Bool MGPipeVerify(const MGPMemoryBarrier& a, const MGPMemoryBarrier& b, const char** outField);
inline Bool MGPipeVerify(const MGPStreamOutputBegin& a, const MGPStreamOutputBegin& b, const char** outField);
inline Bool MGPipeVerify(const MGPXfbAccounting& a, const MGPXfbAccounting& b, const char** outField);
inline Bool MGPipeVerify(const MGPStreamOutputControl& a, const MGPStreamOutputControl& b, const char** outField);
inline Bool MGPipeVerify(const MGPFlush& a, const MGPFlush& b, const char** outField);
inline Bool MGPipeVerify(const MGPPresent& a, const MGPPresent& b, const char** outField);
inline Bool MGPipeVerify(const MGPSwapInterval& a, const MGPSwapInterval& b, const char** outField);
inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const char** outField);
template <>
struct MGPipeHasFieldVerifier<MGPBlobRef> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPRange> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPBox> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPReplySlot> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPStateChunk> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPHandleOnly> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPCaps> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPResourceDesc> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPFenceWait> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPQueryDesc> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPQueryResultRequest> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPTimestampRequest> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPRenderStateDesc> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPBindRenderState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPDynamicState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPVertexElements> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSamplerDesc> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSamplerView> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPTextureParams> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPProgramDesc> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSurface> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPFramebufferState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPVertexBuffer> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPVertexBuffers> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPIndexBuffer> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPIndirectBuffers> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPBoundView> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSamplerViews> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSamplerStates> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPImageView> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPShaderImages> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPBufferRange> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPShaderBuffers> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPStreamOutputTargets> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPGlobalConstants> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPAttribValue> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPVertexAttribDefaults> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPPixelPackState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPPatchState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<ResidualValueBlock> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPResidualValueState> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSubRegion> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSubData> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSubDataComplete> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPFlushRange> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPReadback> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPCopyRegion> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPBlit> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPClear> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPMipPlan> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPReadbackInfo> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPDrawInfo> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPDrawRange> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPDrawIndirect> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPGridInfo> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPMemoryBarrier> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPStreamOutputBegin> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPXfbAccounting> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPStreamOutputControl> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPFlush> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPPresent> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSwapInterval> : std::true_type {};
template <>
struct MGPipeHasFieldVerifier<MGPSurfaceInfo> : std::true_type {};
template <class T>
inline Bool MGPipeFieldEqual(const T& a, const T& b) {
if constexpr (MGPipeHasFieldVerifier<T>::value) {
const char* unusedField = nullptr;
return MGPipeVerify(a, b, &unusedField);
} else if constexpr (std::is_floating_point_v<T>) {
return std::memcmp(&a, &b, sizeof(T)) == 0;
} else if constexpr (std::is_scalar_v<T> || std::is_enum_v<T>) {
return a == b;
} else if constexpr (requires(const T& x, const T& y) { x == y; }) {
return a == b;
} else {
// MEMCMP FALLBACK. Only reached by the payload members that are still MG_State /
// MG_Backend value structs (RenderStateParameters, PixelStoreParameters,
// DynamicBackendParameters) and by MGHostSpan. Those are exactly the types P0.5
// moves into MGPipeValueTypes.h, at which point they get field lists of their own
// and this branch stops being reachable from any payload.
return std::memcmp(&a, &b, sizeof(T)) == 0;
}
}
template <class T, SizeT N>
inline Bool MGPipeFieldEqual(const T (&a)[N], const T (&b)[N]) {
for (SizeT i = 0; i < N; ++i) {
if (!MGPipeFieldEqual(a[i], b[i])) return false;
}
return true;
}
#define MGP_VERIFY_FIELD(FieldName) \
if (!MGPipeFieldEqual(a.FieldName, b.FieldName)) { \
if (outField != nullptr) *outField = #FieldName; \
return false; \
}
inline Bool MGPipeVerify(const MGPBlobRef& a, const MGPBlobRef& b, const char** outField) {
MGP_FIELDS_MGPBlobRef(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPRange& a, const MGPRange& b, const char** outField) {
MGP_FIELDS_MGPRange(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPBox& a, const MGPBox& b, const char** outField) {
MGP_FIELDS_MGPBox(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPReplySlot& a, const MGPReplySlot& b, const char** outField) {
MGP_FIELDS_MGPReplySlot(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPStateChunk& a, const MGPStateChunk& b, const char** outField) {
MGP_FIELDS_MGPStateChunk(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPHandleOnly& a, const MGPHandleOnly& b, const char** outField) {
MGP_FIELDS_MGPHandleOnly(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPCaps& a, const MGPCaps& b, const char** outField) {
MGP_FIELDS_MGPCaps(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPResourceDesc& a, const MGPResourceDesc& b, const char** outField) {
MGP_FIELDS_MGPResourceDesc(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPFenceWait& a, const MGPFenceWait& b, const char** outField) {
MGP_FIELDS_MGPFenceWait(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPQueryDesc& a, const MGPQueryDesc& b, const char** outField) {
MGP_FIELDS_MGPQueryDesc(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPQueryResultRequest& a, const MGPQueryResultRequest& b, const char** outField) {
MGP_FIELDS_MGPQueryResultRequest(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPTimestampRequest& a, const MGPTimestampRequest& b, const char** outField) {
MGP_FIELDS_MGPTimestampRequest(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPRenderStateDesc& a, const MGPRenderStateDesc& b, const char** outField) {
MGP_FIELDS_MGPRenderStateDesc(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPBindRenderState& a, const MGPBindRenderState& b, const char** outField) {
MGP_FIELDS_MGPBindRenderState(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPDynamicState& a, const MGPDynamicState& b, const char** outField) {
MGP_FIELDS_MGPDynamicState(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPVertexElements& a, const MGPVertexElements& b, const char** outField) {
MGP_FIELDS_MGPVertexElements(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSamplerDesc& a, const MGPSamplerDesc& b, const char** outField) {
MGP_FIELDS_MGPSamplerDesc(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSamplerView& a, const MGPSamplerView& b, const char** outField) {
MGP_FIELDS_MGPSamplerView(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPTextureParams& a, const MGPTextureParams& b, const char** outField) {
MGP_FIELDS_MGPTextureParams(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPProgramDesc& a, const MGPProgramDesc& b, const char** outField) {
MGP_FIELDS_MGPProgramDesc(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSurface& a, const MGPSurface& b, const char** outField) {
MGP_FIELDS_MGPSurface(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPFramebufferState& a, const MGPFramebufferState& b, const char** outField) {
MGP_FIELDS_MGPFramebufferState(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPVertexBuffer& a, const MGPVertexBuffer& b, const char** outField) {
MGP_FIELDS_MGPVertexBuffer(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPVertexBuffers& a, const MGPVertexBuffers& b, const char** outField) {
MGP_FIELDS_MGPVertexBuffers(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPIndexBuffer& a, const MGPIndexBuffer& b, const char** outField) {
MGP_FIELDS_MGPIndexBuffer(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPIndirectBuffers& a, const MGPIndirectBuffers& b, const char** outField) {
MGP_FIELDS_MGPIndirectBuffers(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPBoundView& a, const MGPBoundView& b, const char** outField) {
MGP_FIELDS_MGPBoundView(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSamplerViews& a, const MGPSamplerViews& b, const char** outField) {
MGP_FIELDS_MGPSamplerViews(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSamplerStates& a, const MGPSamplerStates& b, const char** outField) {
MGP_FIELDS_MGPSamplerStates(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPImageView& a, const MGPImageView& b, const char** outField) {
MGP_FIELDS_MGPImageView(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPShaderImages& a, const MGPShaderImages& b, const char** outField) {
MGP_FIELDS_MGPShaderImages(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPBufferRange& a, const MGPBufferRange& b, const char** outField) {
MGP_FIELDS_MGPBufferRange(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPShaderBuffers& a, const MGPShaderBuffers& b, const char** outField) {
MGP_FIELDS_MGPShaderBuffers(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPStreamOutputTargets& a, const MGPStreamOutputTargets& b, const char** outField) {
MGP_FIELDS_MGPStreamOutputTargets(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPGlobalConstants& a, const MGPGlobalConstants& b, const char** outField) {
MGP_FIELDS_MGPGlobalConstants(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPAttribValue& a, const MGPAttribValue& b, const char** outField) {
MGP_FIELDS_MGPAttribValue(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPVertexAttribDefaults& a, const MGPVertexAttribDefaults& b, const char** outField) {
MGP_FIELDS_MGPVertexAttribDefaults(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPPixelPackState& a, const MGPPixelPackState& b, const char** outField) {
MGP_FIELDS_MGPPixelPackState(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPPatchState& a, const MGPPatchState& b, const char** outField) {
MGP_FIELDS_MGPPatchState(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const ResidualValueBlock& a, const ResidualValueBlock& b, const char** outField) {
MGP_FIELDS_ResidualValueBlock(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPResidualValueState& a, const MGPResidualValueState& b, const char** outField) {
MGP_FIELDS_MGPResidualValueState(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSubRegion& a, const MGPSubRegion& b, const char** outField) {
MGP_FIELDS_MGPSubRegion(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSubData& a, const MGPSubData& b, const char** outField) {
MGP_FIELDS_MGPSubData(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSubDataComplete& a, const MGPSubDataComplete& b, const char** outField) {
MGP_FIELDS_MGPSubDataComplete(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPFlushRange& a, const MGPFlushRange& b, const char** outField) {
MGP_FIELDS_MGPFlushRange(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPReadback& a, const MGPReadback& b, const char** outField) {
MGP_FIELDS_MGPReadback(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPCopyRegion& a, const MGPCopyRegion& b, const char** outField) {
MGP_FIELDS_MGPCopyRegion(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPBlit& a, const MGPBlit& b, const char** outField) {
MGP_FIELDS_MGPBlit(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPClear& a, const MGPClear& b, const char** outField) {
MGP_FIELDS_MGPClear(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPMipPlan& a, const MGPMipPlan& b, const char** outField) {
MGP_FIELDS_MGPMipPlan(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPReadbackInfo& a, const MGPReadbackInfo& b, const char** outField) {
MGP_FIELDS_MGPReadbackInfo(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPDrawInfo& a, const MGPDrawInfo& b, const char** outField) {
MGP_FIELDS_MGPDrawInfo(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPDrawRange& a, const MGPDrawRange& b, const char** outField) {
MGP_FIELDS_MGPDrawRange(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPDrawIndirect& a, const MGPDrawIndirect& b, const char** outField) {
MGP_FIELDS_MGPDrawIndirect(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPGridInfo& a, const MGPGridInfo& b, const char** outField) {
MGP_FIELDS_MGPGridInfo(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPMemoryBarrier& a, const MGPMemoryBarrier& b, const char** outField) {
MGP_FIELDS_MGPMemoryBarrier(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPStreamOutputBegin& a, const MGPStreamOutputBegin& b, const char** outField) {
MGP_FIELDS_MGPStreamOutputBegin(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPXfbAccounting& a, const MGPXfbAccounting& b, const char** outField) {
MGP_FIELDS_MGPXfbAccounting(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPStreamOutputControl& a, const MGPStreamOutputControl& b, const char** outField) {
MGP_FIELDS_MGPStreamOutputControl(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPFlush& a, const MGPFlush& b, const char** outField) {
MGP_FIELDS_MGPFlush(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPPresent& a, const MGPPresent& b, const char** outField) {
MGP_FIELDS_MGPPresent(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSwapInterval& a, const MGPSwapInterval& b, const char** outField) {
MGP_FIELDS_MGPSwapInterval(MGP_VERIFY_FIELD)
return true;
}
inline Bool MGPipeVerify(const MGPSurfaceInfo& a, const MGPSurfaceInfo& b, const char** outField) {
MGP_FIELDS_MGPSurfaceInfo(MGP_VERIFY_FIELD)
return true;
}
#undef MGP_VERIFY_FIELD
inline constexpr SizeT kMGPipeVerifiedPayloadCount = 63;
+931
View File
@@ -0,0 +1,931 @@
// MobileGL - MobileGL/MG_Pipe/generated/PipeWire.inc
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
// G3: wire records, size assertions and the applier's bounds gate.
//
// GENERATED by scripts/gen_pipe.py from PipeCalls.def - DO NOT EDIT.
// Regenerate with `python3 scripts/gen_pipe.py`; CI runs it and diffs the result.
// This file is included from MG_Pipe/MGPipe.h inside namespace MobileGL::MG_Pipe.
// Every record is a fixed header plus its payload, padded to the stream's 8-byte
// granularity. The size assertion is stated as a COMPOSITION so it fires on any padding
// the compiler inserts between the header and the payload while staying honest about the
// tail padding the alignment requires.
//
// The applier's precondition is checked BEFORE dispatch, on every record, in every build:
// a record that is shorter than its own type, longer than what is left in the buffer, or
// not a multiple of 8 is protocol corruption and is fatal. There is no recovery path -
// silently applying a truncated record is how a corrupt stream becomes a wrong picture.
//
// OVERSIZED PAYLOADS ARE CHUNKED, NEVER EMITTED WHOLE (plan section 8.2: G3 has to define
// the path for a record larger than the segment). The bound is the ring's,
// RingProducer::MaxRecordBytes() == Capacity()/2, and it is exact rather than
// conservative: a record has to be placeable at every head offset of an empty ring, the
// wrap pad in front of it costs up to total-8 bytes, and only a record of at most half the
// ring survives that at every offset. An emitter holding more than Capacity()/2 bytes of
// record (a large resource_subdata, a create_shader_state archive) splits it into several
// records of at most that size; the transport refuses a bigger one outright - nullptr plus
// an MGLOG_E - rather than let the producer wait on free bytes that can never suffice.
struct MGPWireRecHeader {
Uint16 Op; // MGPWireOp
Uint16 Flags; // MGPipeCallFlags of the call, for asserts and tracing
Uint32 Size; // bytes of this record including the header and the variable tail
};
static_assert(sizeof(MGPWireRecHeader) == 8, "the wire header is 8 bytes");
static_assert(std::is_trivially_copyable_v<MGPWireRecHeader>);
// The opcode is the call's position in PipeCalls.def. Reordering that file is a protocol
// break; appending to it is not.
enum class MGPWireOp : Uint16 {
kInvalid = 0,
GetCaps = 1,
ResourceCreate = 2,
ResourceRespecify = 3,
ResourceDestroy = 4,
MapPersistent = 5,
UnmapPersistent = 6,
FenceCreate = 7,
FenceStatus = 8,
FenceWait = 9,
FenceDestroy = 10,
QueryCreate = 11,
QueryBegin = 12,
QueryEnd = 13,
QueryAvailable = 14,
QueryResult = 15,
QueryDestroy = 16,
CreateRenderState = 17,
BindRenderState = 18,
DeleteRenderState = 19,
CreateVertexElements = 20,
BindVertexElements = 21,
DeleteVertexElements = 22,
CreateSamplerState = 23,
DeleteSamplerState = 24,
CreateSamplerView = 25,
DeleteSamplerView = 26,
CreateShaderState = 27,
BindShaderState = 28,
DeleteShaderState = 29,
SetDynamicState = 30,
SetFramebufferState = 31,
SetVertexBuffers = 32,
SetIndexBuffer = 33,
SetIndirectBuffers = 34,
SetSamplerViews = 35,
BindSamplerStates = 36,
SetShaderImages = 37,
SetShaderBuffers = 38,
SetStreamOutputTargets = 39,
SetGlobalConstants = 40,
SetVertexAttribDefaults = 41,
SetPixelPackState = 42,
SetPatchState = 43,
SetDrawProgram = 44,
SetDispatchProgram = 45,
SetResidualValueState = 46,
SetTextureParams = 47,
ResourceSubData = 48,
BufferSubDataResident = 49,
ResourceSubDataComplete = 50,
ResourceFlushRange = 51,
ResourceReadback = 52,
ResourceCopyRegion = 53,
GenerateMipmap = 54,
GetTextureImage = 55,
Blit = 56,
Clear = 57,
ReadPixels = 58,
DrawVbo = 59,
LaunchGrid = 60,
MemoryBarrier = 61,
BeginStreamOutput = 62,
EndStreamOutput = 63,
PauseStreamOutput = 64,
ResumeStreamOutput = 65,
Flush = 66,
Present = 67,
SetSwapInterval = 68,
QueryTimestamp = 69,
QueryCounter = 70,
FenceWaitServer = 71,
kOpCount = 72,
};
struct alignas(8) MGPWireRec_GetCaps {
MGPWireRecHeader Header;
MGPCaps Payload;
};
static_assert(sizeof(MGPWireRec_GetCaps) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPCaps) + 7u) & ~SizeT(7u)),
"MGPWireRec_GetCaps gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceCreate {
MGPWireRecHeader Header;
MGPResourceDesc Payload;
};
static_assert(sizeof(MGPWireRec_ResourceCreate) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPResourceDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceCreate gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceRespecify {
MGPWireRecHeader Header;
MGPResourceDesc Payload;
};
static_assert(sizeof(MGPWireRec_ResourceRespecify) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPResourceDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceRespecify gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceDestroy {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_ResourceDestroy) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceDestroy gained padding; the wire format moved");
struct alignas(8) MGPWireRec_MapPersistent {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_MapPersistent) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_MapPersistent gained padding; the wire format moved");
struct alignas(8) MGPWireRec_UnmapPersistent {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_UnmapPersistent) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_UnmapPersistent gained padding; the wire format moved");
struct alignas(8) MGPWireRec_FenceCreate {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_FenceCreate) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_FenceCreate gained padding; the wire format moved");
struct alignas(8) MGPWireRec_FenceStatus {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_FenceStatus) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_FenceStatus gained padding; the wire format moved");
struct alignas(8) MGPWireRec_FenceWait {
MGPWireRecHeader Header;
MGPFenceWait Payload;
};
static_assert(sizeof(MGPWireRec_FenceWait) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPFenceWait) + 7u) & ~SizeT(7u)),
"MGPWireRec_FenceWait gained padding; the wire format moved");
struct alignas(8) MGPWireRec_FenceDestroy {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_FenceDestroy) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_FenceDestroy gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryCreate {
MGPWireRecHeader Header;
MGPQueryDesc Payload;
};
static_assert(sizeof(MGPWireRec_QueryCreate) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryCreate gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryBegin {
MGPWireRecHeader Header;
MGPQueryDesc Payload;
};
static_assert(sizeof(MGPWireRec_QueryBegin) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryBegin gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryEnd {
MGPWireRecHeader Header;
MGPQueryDesc Payload;
};
static_assert(sizeof(MGPWireRec_QueryEnd) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryEnd gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryAvailable {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_QueryAvailable) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryAvailable gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryResult {
MGPWireRecHeader Header;
MGPQueryResultRequest Payload;
};
static_assert(sizeof(MGPWireRec_QueryResult) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPQueryResultRequest) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryResult gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryDestroy {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_QueryDestroy) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryDestroy gained padding; the wire format moved");
struct alignas(8) MGPWireRec_CreateRenderState {
MGPWireRecHeader Header;
MGPRenderStateDesc Payload;
};
static_assert(sizeof(MGPWireRec_CreateRenderState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPRenderStateDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_CreateRenderState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_BindRenderState {
MGPWireRecHeader Header;
MGPBindRenderState Payload;
};
static_assert(sizeof(MGPWireRec_BindRenderState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPBindRenderState) + 7u) & ~SizeT(7u)),
"MGPWireRec_BindRenderState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_DeleteRenderState {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_DeleteRenderState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_DeleteRenderState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_CreateVertexElements {
MGPWireRecHeader Header;
MGPVertexElements Payload;
};
static_assert(sizeof(MGPWireRec_CreateVertexElements) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPVertexElements) + 7u) & ~SizeT(7u)),
"MGPWireRec_CreateVertexElements gained padding; the wire format moved");
struct alignas(8) MGPWireRec_BindVertexElements {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_BindVertexElements) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_BindVertexElements gained padding; the wire format moved");
struct alignas(8) MGPWireRec_DeleteVertexElements {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_DeleteVertexElements) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_DeleteVertexElements gained padding; the wire format moved");
struct alignas(8) MGPWireRec_CreateSamplerState {
MGPWireRecHeader Header;
MGPSamplerDesc Payload;
};
static_assert(sizeof(MGPWireRec_CreateSamplerState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_CreateSamplerState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_DeleteSamplerState {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_DeleteSamplerState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_DeleteSamplerState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_CreateSamplerView {
MGPWireRecHeader Header;
MGPSamplerView Payload;
};
static_assert(sizeof(MGPWireRec_CreateSamplerView) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerView) + 7u) & ~SizeT(7u)),
"MGPWireRec_CreateSamplerView gained padding; the wire format moved");
struct alignas(8) MGPWireRec_DeleteSamplerView {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_DeleteSamplerView) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_DeleteSamplerView gained padding; the wire format moved");
struct alignas(8) MGPWireRec_CreateShaderState {
MGPWireRecHeader Header;
MGPProgramDesc Payload;
};
static_assert(sizeof(MGPWireRec_CreateShaderState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPProgramDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_CreateShaderState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_BindShaderState {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_BindShaderState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_BindShaderState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_DeleteShaderState {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_DeleteShaderState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_DeleteShaderState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetDynamicState {
MGPWireRecHeader Header;
MGPDynamicState Payload;
};
static_assert(sizeof(MGPWireRec_SetDynamicState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPDynamicState) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetDynamicState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetFramebufferState {
MGPWireRecHeader Header;
MGPFramebufferState Payload;
};
static_assert(sizeof(MGPWireRec_SetFramebufferState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPFramebufferState) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetFramebufferState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetVertexBuffers {
MGPWireRecHeader Header;
MGPVertexBuffers Payload;
};
static_assert(sizeof(MGPWireRec_SetVertexBuffers) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPVertexBuffers) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetVertexBuffers gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetIndexBuffer {
MGPWireRecHeader Header;
MGPIndexBuffer Payload;
};
static_assert(sizeof(MGPWireRec_SetIndexBuffer) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPIndexBuffer) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetIndexBuffer gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetIndirectBuffers {
MGPWireRecHeader Header;
MGPIndirectBuffers Payload;
};
static_assert(sizeof(MGPWireRec_SetIndirectBuffers) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPIndirectBuffers) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetIndirectBuffers gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetSamplerViews {
MGPWireRecHeader Header;
MGPSamplerViews Payload;
};
static_assert(sizeof(MGPWireRec_SetSamplerViews) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerViews) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetSamplerViews gained padding; the wire format moved");
struct alignas(8) MGPWireRec_BindSamplerStates {
MGPWireRecHeader Header;
MGPSamplerStates Payload;
};
static_assert(sizeof(MGPWireRec_BindSamplerStates) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSamplerStates) + 7u) & ~SizeT(7u)),
"MGPWireRec_BindSamplerStates gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetShaderImages {
MGPWireRecHeader Header;
MGPShaderImages Payload;
};
static_assert(sizeof(MGPWireRec_SetShaderImages) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPShaderImages) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetShaderImages gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetShaderBuffers {
MGPWireRecHeader Header;
MGPShaderBuffers Payload;
};
static_assert(sizeof(MGPWireRec_SetShaderBuffers) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPShaderBuffers) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetShaderBuffers gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetStreamOutputTargets {
MGPWireRecHeader Header;
MGPStreamOutputTargets Payload;
};
static_assert(sizeof(MGPWireRec_SetStreamOutputTargets) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputTargets) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetStreamOutputTargets gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetGlobalConstants {
MGPWireRecHeader Header;
MGPGlobalConstants Payload;
};
static_assert(sizeof(MGPWireRec_SetGlobalConstants) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPGlobalConstants) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetGlobalConstants gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetVertexAttribDefaults {
MGPWireRecHeader Header;
MGPVertexAttribDefaults Payload;
};
static_assert(sizeof(MGPWireRec_SetVertexAttribDefaults) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPVertexAttribDefaults) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetVertexAttribDefaults gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetPixelPackState {
MGPWireRecHeader Header;
MGPPixelPackState Payload;
};
static_assert(sizeof(MGPWireRec_SetPixelPackState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPPixelPackState) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetPixelPackState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetPatchState {
MGPWireRecHeader Header;
MGPPatchState Payload;
};
static_assert(sizeof(MGPWireRec_SetPatchState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPPatchState) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetPatchState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetDrawProgram {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_SetDrawProgram) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetDrawProgram gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetDispatchProgram {
MGPWireRecHeader Header;
MGPHandleOnly Payload;
};
static_assert(sizeof(MGPWireRec_SetDispatchProgram) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPHandleOnly) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetDispatchProgram gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetResidualValueState {
MGPWireRecHeader Header;
MGPResidualValueState Payload;
};
static_assert(sizeof(MGPWireRec_SetResidualValueState) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPResidualValueState) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetResidualValueState gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetTextureParams {
MGPWireRecHeader Header;
MGPTextureParams Payload;
};
static_assert(sizeof(MGPWireRec_SetTextureParams) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPTextureParams) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetTextureParams gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceSubData {
MGPWireRecHeader Header;
MGPSubData Payload;
};
static_assert(sizeof(MGPWireRec_ResourceSubData) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSubData) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceSubData gained padding; the wire format moved");
struct alignas(8) MGPWireRec_BufferSubDataResident {
MGPWireRecHeader Header;
MGPSubData Payload;
};
static_assert(sizeof(MGPWireRec_BufferSubDataResident) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSubData) + 7u) & ~SizeT(7u)),
"MGPWireRec_BufferSubDataResident gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceSubDataComplete {
MGPWireRecHeader Header;
MGPSubDataComplete Payload;
};
static_assert(sizeof(MGPWireRec_ResourceSubDataComplete) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSubDataComplete) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceSubDataComplete gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceFlushRange {
MGPWireRecHeader Header;
MGPFlushRange Payload;
};
static_assert(sizeof(MGPWireRec_ResourceFlushRange) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPFlushRange) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceFlushRange gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceReadback {
MGPWireRecHeader Header;
MGPReadback Payload;
};
static_assert(sizeof(MGPWireRec_ResourceReadback) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPReadback) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceReadback gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResourceCopyRegion {
MGPWireRecHeader Header;
MGPCopyRegion Payload;
};
static_assert(sizeof(MGPWireRec_ResourceCopyRegion) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPCopyRegion) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResourceCopyRegion gained padding; the wire format moved");
struct alignas(8) MGPWireRec_GenerateMipmap {
MGPWireRecHeader Header;
MGPMipPlan Payload;
};
static_assert(sizeof(MGPWireRec_GenerateMipmap) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPMipPlan) + 7u) & ~SizeT(7u)),
"MGPWireRec_GenerateMipmap gained padding; the wire format moved");
struct alignas(8) MGPWireRec_GetTextureImage {
MGPWireRecHeader Header;
MGPReadbackInfo Payload;
};
static_assert(sizeof(MGPWireRec_GetTextureImage) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPReadbackInfo) + 7u) & ~SizeT(7u)),
"MGPWireRec_GetTextureImage gained padding; the wire format moved");
struct alignas(8) MGPWireRec_Blit {
MGPWireRecHeader Header;
MGPBlit Payload;
};
static_assert(sizeof(MGPWireRec_Blit) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPBlit) + 7u) & ~SizeT(7u)),
"MGPWireRec_Blit gained padding; the wire format moved");
struct alignas(8) MGPWireRec_Clear {
MGPWireRecHeader Header;
MGPClear Payload;
};
static_assert(sizeof(MGPWireRec_Clear) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPClear) + 7u) & ~SizeT(7u)),
"MGPWireRec_Clear gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ReadPixels {
MGPWireRecHeader Header;
MGPReadbackInfo Payload;
};
static_assert(sizeof(MGPWireRec_ReadPixels) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPReadbackInfo) + 7u) & ~SizeT(7u)),
"MGPWireRec_ReadPixels gained padding; the wire format moved");
struct alignas(8) MGPWireRec_DrawVbo {
MGPWireRecHeader Header;
MGPDrawInfo Payload;
};
static_assert(sizeof(MGPWireRec_DrawVbo) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPDrawInfo) + 7u) & ~SizeT(7u)),
"MGPWireRec_DrawVbo gained padding; the wire format moved");
struct alignas(8) MGPWireRec_LaunchGrid {
MGPWireRecHeader Header;
MGPGridInfo Payload;
};
static_assert(sizeof(MGPWireRec_LaunchGrid) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPGridInfo) + 7u) & ~SizeT(7u)),
"MGPWireRec_LaunchGrid gained padding; the wire format moved");
struct alignas(8) MGPWireRec_MemoryBarrier {
MGPWireRecHeader Header;
MGPMemoryBarrier Payload;
};
static_assert(sizeof(MGPWireRec_MemoryBarrier) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPMemoryBarrier) + 7u) & ~SizeT(7u)),
"MGPWireRec_MemoryBarrier gained padding; the wire format moved");
struct alignas(8) MGPWireRec_BeginStreamOutput {
MGPWireRecHeader Header;
MGPStreamOutputBegin Payload;
};
static_assert(sizeof(MGPWireRec_BeginStreamOutput) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputBegin) + 7u) & ~SizeT(7u)),
"MGPWireRec_BeginStreamOutput gained padding; the wire format moved");
struct alignas(8) MGPWireRec_EndStreamOutput {
MGPWireRecHeader Header;
MGPXfbAccounting Payload;
};
static_assert(sizeof(MGPWireRec_EndStreamOutput) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPXfbAccounting) + 7u) & ~SizeT(7u)),
"MGPWireRec_EndStreamOutput gained padding; the wire format moved");
struct alignas(8) MGPWireRec_PauseStreamOutput {
MGPWireRecHeader Header;
MGPStreamOutputControl Payload;
};
static_assert(sizeof(MGPWireRec_PauseStreamOutput) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputControl) + 7u) & ~SizeT(7u)),
"MGPWireRec_PauseStreamOutput gained padding; the wire format moved");
struct alignas(8) MGPWireRec_ResumeStreamOutput {
MGPWireRecHeader Header;
MGPStreamOutputControl Payload;
};
static_assert(sizeof(MGPWireRec_ResumeStreamOutput) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPStreamOutputControl) + 7u) & ~SizeT(7u)),
"MGPWireRec_ResumeStreamOutput gained padding; the wire format moved");
struct alignas(8) MGPWireRec_Flush {
MGPWireRecHeader Header;
MGPFlush Payload;
};
static_assert(sizeof(MGPWireRec_Flush) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPFlush) + 7u) & ~SizeT(7u)),
"MGPWireRec_Flush gained padding; the wire format moved");
struct alignas(8) MGPWireRec_Present {
MGPWireRecHeader Header;
MGPPresent Payload;
};
static_assert(sizeof(MGPWireRec_Present) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPPresent) + 7u) & ~SizeT(7u)),
"MGPWireRec_Present gained padding; the wire format moved");
struct alignas(8) MGPWireRec_SetSwapInterval {
MGPWireRecHeader Header;
MGPSwapInterval Payload;
};
static_assert(sizeof(MGPWireRec_SetSwapInterval) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPSwapInterval) + 7u) & ~SizeT(7u)),
"MGPWireRec_SetSwapInterval gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryTimestamp {
MGPWireRecHeader Header;
MGPTimestampRequest Payload;
};
static_assert(sizeof(MGPWireRec_QueryTimestamp) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPTimestampRequest) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryTimestamp gained padding; the wire format moved");
struct alignas(8) MGPWireRec_QueryCounter {
MGPWireRecHeader Header;
MGPQueryDesc Payload;
};
static_assert(sizeof(MGPWireRec_QueryCounter) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPQueryDesc) + 7u) & ~SizeT(7u)),
"MGPWireRec_QueryCounter gained padding; the wire format moved");
struct alignas(8) MGPWireRec_FenceWaitServer {
MGPWireRecHeader Header;
MGPFenceWait Payload;
};
static_assert(sizeof(MGPWireRec_FenceWaitServer) ==
((sizeof(MGPWireRecHeader) + sizeof(MGPFenceWait) + 7u) & ~SizeT(7u)),
"MGPWireRec_FenceWaitServer gained padding; the wire format moved");
[[noreturn]] inline void MGPipeWireProtocolFatal(const char* call, Uint64 size, Uint64 remaining) {
MGLOG_F("MGPipe: protocol corruption applying %s: size=%llu remaining=%llu", call,
static_cast<unsigned long long>(size), static_cast<unsigned long long>(remaining));
std::abort();
}
#define MGP_WIRE_CHECK_BOUNDS(RecType, CallName) \
do { \
if (!(size >= sizeof(RecType) && size <= remaining && (size % 8) == 0)) { \
MGPipeWireProtocolFatal(CallName, size, remaining); \
} \
} while (0)
// Returns whether the record was applied. P0 is a SKELETON: every case validates its
// bounds and then reports "not applied", because no applier exists until P5 wires
// MG_Remote/Server/PipeApplier.cpp to the real backend tables. The switch and the opcode
// enum come from the same list, so a call added to the catalogue cannot be forgotten here;
// the default arm is for the opcode that never came from this catalogue at all - a byte
// off a corrupt stream - and it is fatal for the same reason the bounds check is.
inline Bool MGPipeApplyWireRecord(MGPWireOp op, const void* record, Uint64 size, Uint64 remaining) {
(void)record;
switch (op) {
case MGPWireOp::GetCaps:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GetCaps, "GetCaps");
return false;
case MGPWireOp::ResourceCreate:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceCreate, "ResourceCreate");
return false;
case MGPWireOp::ResourceRespecify:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceRespecify, "ResourceRespecify");
return false;
case MGPWireOp::ResourceDestroy:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceDestroy, "ResourceDestroy");
return false;
case MGPWireOp::MapPersistent:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_MapPersistent, "MapPersistent");
return false;
case MGPWireOp::UnmapPersistent:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_UnmapPersistent, "UnmapPersistent");
return false;
case MGPWireOp::FenceCreate:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceCreate, "FenceCreate");
return false;
case MGPWireOp::FenceStatus:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceStatus, "FenceStatus");
return false;
case MGPWireOp::FenceWait:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceWait, "FenceWait");
return false;
case MGPWireOp::FenceDestroy:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceDestroy, "FenceDestroy");
return false;
case MGPWireOp::QueryCreate:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryCreate, "QueryCreate");
return false;
case MGPWireOp::QueryBegin:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryBegin, "QueryBegin");
return false;
case MGPWireOp::QueryEnd:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryEnd, "QueryEnd");
return false;
case MGPWireOp::QueryAvailable:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryAvailable, "QueryAvailable");
return false;
case MGPWireOp::QueryResult:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryResult, "QueryResult");
return false;
case MGPWireOp::QueryDestroy:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryDestroy, "QueryDestroy");
return false;
case MGPWireOp::CreateRenderState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateRenderState, "CreateRenderState");
return false;
case MGPWireOp::BindRenderState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindRenderState, "BindRenderState");
return false;
case MGPWireOp::DeleteRenderState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteRenderState, "DeleteRenderState");
return false;
case MGPWireOp::CreateVertexElements:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateVertexElements, "CreateVertexElements");
return false;
case MGPWireOp::BindVertexElements:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindVertexElements, "BindVertexElements");
return false;
case MGPWireOp::DeleteVertexElements:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteVertexElements, "DeleteVertexElements");
return false;
case MGPWireOp::CreateSamplerState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateSamplerState, "CreateSamplerState");
return false;
case MGPWireOp::DeleteSamplerState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteSamplerState, "DeleteSamplerState");
return false;
case MGPWireOp::CreateSamplerView:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateSamplerView, "CreateSamplerView");
return false;
case MGPWireOp::DeleteSamplerView:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteSamplerView, "DeleteSamplerView");
return false;
case MGPWireOp::CreateShaderState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_CreateShaderState, "CreateShaderState");
return false;
case MGPWireOp::BindShaderState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindShaderState, "BindShaderState");
return false;
case MGPWireOp::DeleteShaderState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DeleteShaderState, "DeleteShaderState");
return false;
case MGPWireOp::SetDynamicState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDynamicState, "SetDynamicState");
return false;
case MGPWireOp::SetFramebufferState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetFramebufferState, "SetFramebufferState");
return false;
case MGPWireOp::SetVertexBuffers:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetVertexBuffers, "SetVertexBuffers");
return false;
case MGPWireOp::SetIndexBuffer:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetIndexBuffer, "SetIndexBuffer");
return false;
case MGPWireOp::SetIndirectBuffers:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetIndirectBuffers, "SetIndirectBuffers");
return false;
case MGPWireOp::SetSamplerViews:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetSamplerViews, "SetSamplerViews");
return false;
case MGPWireOp::BindSamplerStates:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BindSamplerStates, "BindSamplerStates");
return false;
case MGPWireOp::SetShaderImages:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetShaderImages, "SetShaderImages");
return false;
case MGPWireOp::SetShaderBuffers:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetShaderBuffers, "SetShaderBuffers");
return false;
case MGPWireOp::SetStreamOutputTargets:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetStreamOutputTargets, "SetStreamOutputTargets");
return false;
case MGPWireOp::SetGlobalConstants:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetGlobalConstants, "SetGlobalConstants");
return false;
case MGPWireOp::SetVertexAttribDefaults:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetVertexAttribDefaults, "SetVertexAttribDefaults");
return false;
case MGPWireOp::SetPixelPackState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetPixelPackState, "SetPixelPackState");
return false;
case MGPWireOp::SetPatchState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetPatchState, "SetPatchState");
return false;
case MGPWireOp::SetDrawProgram:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDrawProgram, "SetDrawProgram");
return false;
case MGPWireOp::SetDispatchProgram:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetDispatchProgram, "SetDispatchProgram");
return false;
case MGPWireOp::SetResidualValueState:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetResidualValueState, "SetResidualValueState");
return false;
case MGPWireOp::SetTextureParams:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetTextureParams, "SetTextureParams");
return false;
case MGPWireOp::ResourceSubData:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceSubData, "ResourceSubData");
return false;
case MGPWireOp::BufferSubDataResident:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BufferSubDataResident, "BufferSubDataResident");
return false;
case MGPWireOp::ResourceSubDataComplete:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceSubDataComplete, "ResourceSubDataComplete");
return false;
case MGPWireOp::ResourceFlushRange:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceFlushRange, "ResourceFlushRange");
return false;
case MGPWireOp::ResourceReadback:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceReadback, "ResourceReadback");
return false;
case MGPWireOp::ResourceCopyRegion:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResourceCopyRegion, "ResourceCopyRegion");
return false;
case MGPWireOp::GenerateMipmap:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GenerateMipmap, "GenerateMipmap");
return false;
case MGPWireOp::GetTextureImage:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_GetTextureImage, "GetTextureImage");
return false;
case MGPWireOp::Blit:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Blit, "Blit");
return false;
case MGPWireOp::Clear:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Clear, "Clear");
return false;
case MGPWireOp::ReadPixels:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ReadPixels, "ReadPixels");
return false;
case MGPWireOp::DrawVbo:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_DrawVbo, "DrawVbo");
return false;
case MGPWireOp::LaunchGrid:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_LaunchGrid, "LaunchGrid");
return false;
case MGPWireOp::MemoryBarrier:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_MemoryBarrier, "MemoryBarrier");
return false;
case MGPWireOp::BeginStreamOutput:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_BeginStreamOutput, "BeginStreamOutput");
return false;
case MGPWireOp::EndStreamOutput:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_EndStreamOutput, "EndStreamOutput");
return false;
case MGPWireOp::PauseStreamOutput:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_PauseStreamOutput, "PauseStreamOutput");
return false;
case MGPWireOp::ResumeStreamOutput:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_ResumeStreamOutput, "ResumeStreamOutput");
return false;
case MGPWireOp::Flush:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Flush, "Flush");
return false;
case MGPWireOp::Present:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_Present, "Present");
return false;
case MGPWireOp::SetSwapInterval:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_SetSwapInterval, "SetSwapInterval");
return false;
case MGPWireOp::QueryTimestamp:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryTimestamp, "QueryTimestamp");
return false;
case MGPWireOp::QueryCounter:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_QueryCounter, "QueryCounter");
return false;
case MGPWireOp::FenceWaitServer:
MGP_WIRE_CHECK_BOUNDS(MGPWireRec_FenceWaitServer, "FenceWaitServer");
return false;
case MGPWireOp::kInvalid:
case MGPWireOp::kOpCount:
default:
MGPipeWireProtocolFatal("<unknown opcode>", size, remaining);
}
}
#undef MGP_WIRE_CHECK_BOUNDS
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
// MobileGL - MobileGL/MG_Remote/Protocol/mg_protocol_base.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
// Shared vocabulary of the MG_Remote wire contracts (transport, framing, ring,
// shm). Inherited from the earlier `Feat/CS-Delta-IPC` branch
// (MobileGL/Protocol/mg_protocol_base.h) and cut down to what plan B's
// transport actually needs: result codes, byte spans, a shm region reference
// and the id typedefs.
//
// Deliberately NOT inherited: MobileGLObjectKind / MobileGLObjectScope /
// MobileGLObjectHandle. Plan B does not put GL object identity on the wire at
// all - the frontend allocates {slot, generation} handles in MG_Pipe
// (PLAN-B.md section 4.2.1) and those are the only identity the backend ever
// sees, so a second object-identity vocabulary here would be a drift surface
// with no reader.
//
// This header must stay:
// - pure C (compilable from C and C++, no MG C++ types, no exceptions/RTTI),
// - dependency-free (only <stdbool.h>/<stddef.h>/<stdint.h>),
// - append-only within an ABI major (see versioning rules below).
//
// Versioning rules (contract-wide):
// - Every versioned struct starts with uint32_t structSize.
// - Appending fields at the tail is a MINOR bump; receivers must ignore
// bytes beyond the structSize they know.
// - Changing/removing/reordering existing fields is a MAJOR bump.
// - A major mismatch is a hard, structured failure, never an exception.
// (Plan B keeps the structSize-first discipline as the answer to risk B-R10,
// PLAN-B.md section 14.2.)
#ifndef MOBILEGL_REMOTE_PROTOCOL_BASE_H
#define MOBILEGL_REMOTE_PROTOCOL_BASE_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// ---------------------------------------------------------------------------
// ABI versions
// ---------------------------------------------------------------------------
#define MOBILEGL_PROTOCOL_ABI_MAJOR 1
#define MOBILEGL_PROTOCOL_ABI_MINOR 0
#define MOBILEGL_ABI_VERSION(major, minor) (((uint32_t)(major) << 16) | (uint32_t)(minor))
#define MOBILEGL_ABI_MAJOR_OF(version) ((uint32_t)(version) >> 16)
#define MOBILEGL_ABI_MINOR_OF(version) ((uint32_t)(version) & 0xFFFFu)
// ---------------------------------------------------------------------------
// Ids
// ---------------------------------------------------------------------------
typedef uint64_t MobileGLSessionId; // one client GL context flow
typedef uint64_t MobileGLRequestSeq; // matches a request to its reply
typedef uint32_t MobileGLSegmentId; // shm segment id within a connection
// ---------------------------------------------------------------------------
// Spans / regions
// ---------------------------------------------------------------------------
// Borrowed, read-only byte span. The pointee is owned by the producing side
// and is only valid for the duration documented at the consuming call site.
typedef struct MobileGLByteSpan {
const void* data;
uint64_t size;
} MobileGLByteSpan;
typedef struct MobileGLMutableByteSpan {
void* data;
uint64_t size;
} MobileGLMutableByteSpan;
// A byte range inside an already-established shm segment. Segments are
// announced out of band (the SegmentRef table on the control channel, with the
// fd itself passed by SCM_RIGHTS) and stay stable for their declared lifetime;
// offsets are segment-relative.
typedef struct MobileGLShmRegion {
MobileGLSegmentId segmentId;
uint32_t reserved;
uint64_t offset;
uint64_t size;
} MobileGLShmRegion;
// ---------------------------------------------------------------------------
// Result codes (structured errors across every contract boundary)
// ---------------------------------------------------------------------------
typedef enum MobileGLResult {
MOBILEGL_OK = 0,
MOBILEGL_ERR_NOT_INITIALIZED = 1,
MOBILEGL_ERR_INVALID_ARGUMENT = 2,
MOBILEGL_ERR_UNSUPPORTED = 3,
MOBILEGL_ERR_OUT_OF_MEMORY = 4,
MOBILEGL_ERR_PROTOCOL_MISMATCH = 5, // ABI/wire major mismatch, bad framing
MOBILEGL_ERR_TRANSPORT_CLOSED = 6, // peer gone / EOF
MOBILEGL_ERR_TIMEOUT = 7, // nothing arrived within the deadline
MOBILEGL_ERR_SHM_EXHAUSTED = 8,
MOBILEGL_ERR_SESSION_UNKNOWN = 9,
MOBILEGL_ERR_HANDLE_UNKNOWN = 10,
// The caller's buffer is smaller than the pending message. The message is
// NOT consumed and the required size is reported back; see
// ITransport::ReceiveFrame.
MOBILEGL_ERR_BUFFER_TOO_SMALL = 11,
MOBILEGL_ERR_FORCE_U32 = 0x7FFFFFFF
} MobileGLResult;
#ifdef __cplusplus
} // extern "C"
#endif
#endif // MOBILEGL_REMOTE_PROTOCOL_BASE_H
+235
View File
@@ -0,0 +1,235 @@
// MobileGL - MobileGL/MG_Remote/Protocol/protocol.fbs
// 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
// MobileGL disaggregated wire protocol - CONTROL PLANE ONLY.
//
// Plan B (docs plan "MGPipe") section 8.1 inherits the transport design of the
// earlier plan verbatim, and its section 7.1 splits the schema in two:
//
// - rare / variable-length / must-evolve messages -> FlatBuffers *tables*,
// carried as complete framed messages over the control channel. That is
// everything in this file.
// - the hot path -> FlatBuffers *structs* (fixed layout, no vtable, no
// offset indirection) written straight into the SEG_CMD ring. Those
// records are generated from MG_Pipe/PipeCalls.def and are deliberately
// NOT in this schema yet: the call catalogue is a separate P0 deliverable
// and record numbering must never churn.
//
// Regeneration: scripts/gen_protocol.py (flatc is NOT part of the default
// build graph). generated/protocol_generated.h is committed and CI's
// flatc-check regenerates it and runs `git diff --exit-code`.
namespace MobileGL.Wire;
// ---------------------------------------------------------------------------
// Segments
// ---------------------------------------------------------------------------
// Segment layout is inherited unchanged (earlier plan section 6.1):
// SEG_CMD 8MiB / SEG_STAGE 32MiB+ / SEG_REPLY 8MiB / SEG_EVENT 256KiB /
// SEG_SHADOW[n] / SEG_ADOPT[n].
enum SegmentKind : ubyte {
None = 0,
Cmd = 1, // client-owned command ring (RingControl + records)
Stage = 2, // client-owned bulk staging
Reply = 3, // server-owned reply pool
Event = 4, // server-owned event ring
Shadow = 5, // client-owned per-object shadow (P4.5+)
Adopt = 6, // server-owned adopted store, client RW (>= 16MiB)
}
// The fd itself never travels in a message: POSIX passes it with SCM_RIGHTS on
// the aux socket (ITransport::ShareFd), Windows resolves `name`.
table SegmentRef {
id: uint;
kind: SegmentKind;
sizeBytes: ulong;
name: string;
}
// ---------------------------------------------------------------------------
// Handshake
// ---------------------------------------------------------------------------
table Hello {
abiMajor: uint;
abiMinor: uint;
buildFingerprint: string;
backendType: uint;
pid: uint;
configBlob: [ubyte];
}
table Welcome {
abiMajor: uint;
abiMinor: uint;
serverPid: uint;
cmdRing: SegmentRef;
stageRing: SegmentRef;
replyPool: SegmentRef;
eventRing: SegmentRef;
}
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
// Replaces the 40 `pActiveBackendObject->` reads plus the 89 caps read sites
// (plan B appendix A, `get_caps`). The three blobs are byte-for-byte images of
// the corresponding POD structs; they are versioned by structSize-first
// discipline, not by this schema.
table CapsSnapshot {
dynamicParameters: [ubyte];
rendererInfo: [ubyte];
formatCaps: [ubyte];
extensions: [string];
apiVersion: string;
maxComputeWorkGroupCount: [int]; // 3 entries
maxComputeWorkGroupSize: [int]; // 3 entries
tableSlotMask: ulong; // which GLFunctionsTable slots the peer registered
prefersCpuXfbPrimitiveAccounting: bool;
}
table DefaultFramebufferInfo {
width: int;
height: int;
colorFormat: uint;
depthFormat: uint;
stencilFormat: uint;
}
// ---------------------------------------------------------------------------
// Surface / EGL lifecycle
// ---------------------------------------------------------------------------
enum SurfaceOpKind : ubyte {
None = 0,
InitializeDisplay = 1,
CreateWindowSurface = 2,
CreatePbufferSurface = 3,
ResizeWindowSurface = 4,
ReleaseSurface = 5,
MakeCurrent = 6,
ReleaseCurrent = 7,
}
enum WindowKind : ubyte {
None = 0,
AndroidNativeWindow = 1,
X11 = 2,
Win32Hwnd = 3,
Surfaceless = 4,
Pbuffer = 5,
}
table SurfaceOp {
seq: ulong;
kind: SurfaceOpKind;
display: ulong;
surface: ulong;
windowKind: WindowKind;
nativeToken: ulong; // X11 XID / HWND; Android transfers the window out of band
width: int;
height: int;
swapInterval: int;
}
table SurfaceReply {
seq: ulong;
ok: bool;
eglMajor: int;
eglMinor: int;
defaultFb: DefaultFramebufferInfo;
}
// ---------------------------------------------------------------------------
// Resync / aux / diagnostics
// ---------------------------------------------------------------------------
// Sent by the client after it observes a serverEpoch bump (context lost or
// server restart): every cached ring offset and every server-side object is
// gone and the whole pushed state has to be replayed.
table ResyncRequest {
serverEpoch: uint;
}
table ResyncDone {}
enum AuxRequestKind : ubyte {
None = 0,
FenceClientWait = 1,
QueryResult = 2,
ScalarGet = 3,
}
// Requests issued from a thread that is not the ring producer (foreign-thread
// sync / query polling), so they cannot take the SPSC ring.
table AuxRequest {
seq: ulong;
kind: AuxRequestKind;
payload: [ubyte];
}
enum FatalCode : uint {
None = 0,
ProtocolCorruption = 1, // record bounds / self-describing length violated
RingOverrun = 2,
SegmentMismatch = 3,
DeviceLost = 4,
ServerCrashed = 5,
AbiMismatch = 6,
}
table Fatal {
code: FatalCode;
message: string;
}
// Severity-graded per plan B section 8.2: <= Warn is lossy, >= Error is
// lossless and rate limited.
enum LogLevel : ubyte {
Debug = 0,
Info = 1,
Warn = 2,
Error = 3,
Fatal = 4,
}
table LogLine {
level: LogLevel;
text: string;
}
// ---------------------------------------------------------------------------
// Envelope
// ---------------------------------------------------------------------------
// Union tags are wire values: only ever APPEND to this list.
// ProgramReflection from the earlier plan's section 7.1 is intentionally
// absent - plan B ships program artifacts inside the create_shader_state CSO
// blob, so if a control-plane reflection message is ever needed it appends
// here rather than reserving a tag today.
union CtrlMsg {
Hello,
Welcome,
CapsSnapshot,
SurfaceOp,
SurfaceReply,
ResyncRequest,
ResyncDone,
AuxRequest,
Fatal,
LogLine,
}
table CtrlEnvelope {
msg: CtrlMsg;
}
root_type CtrlEnvelope;
file_identifier "MGLC";
+259
View File
@@ -0,0 +1,259 @@
// MobileGL - MobileGL/MG_Remote/Transport/Doorbell.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 "Doorbell.h"
#include <MG_Util/Debug/Log.h>
#include <condition_variable>
#include <mutex>
#if !defined(_WIN32)
#include <cerrno>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#endif
// Same fallback as FdPassing.cpp: on macOS / BSD the protection is SO_NOSIGPIPE on the
// socket, set in SocketDoorbell's constructor, not a per-send flag.
#if !defined(_WIN32) && !defined(MSG_NOSIGNAL)
#define MSG_NOSIGNAL 0
#endif
namespace MobileGL::MG_Remote::Transport {
// -----------------------------------------------------------------------
// CondVarDoorbell
// -----------------------------------------------------------------------
struct CondVarDoorbell::Impl {
std::mutex mutex;
std::condition_variable cv;
// Counted, not a flag: a wakeup that arrives while nobody is parked
// must still be observed by the next Park.
std::uint32_t signals = 0;
};
CondVarDoorbell::CondVarDoorbell() : m_impl(new Impl()) {}
CondVarDoorbell::~CondVarDoorbell() { delete m_impl; }
void CondVarDoorbell::Notify() {
{
std::lock_guard<std::mutex> lock(m_impl->mutex);
++m_impl->signals;
}
m_impl->cv.notify_one();
}
bool CondVarDoorbell::Park(std::uint32_t timeoutMs) {
std::unique_lock<std::mutex> lock(m_impl->mutex);
// The death latch is tested under the same mutex Kill sets it under, so
// a Kill cannot slip between this test and the wait below: it either
// returns here or wakes the predicate.
if (m_dead.load(std::memory_order_relaxed)) {
return false;
}
if (m_impl->signals != 0) {
--m_impl->signals;
return true;
}
if (timeoutMs == 0) {
return false;
}
const auto woken = [this] {
return m_impl->signals != 0 || m_dead.load(std::memory_order_relaxed);
};
if (timeoutMs == kWaitForever) {
m_impl->cv.wait(lock, woken);
} else if (!m_impl->cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), woken)) {
return false;
}
if (m_dead.load(std::memory_order_relaxed)) {
// Woken by Kill, not by an event. The caller re-tests its condition
// regardless (Doorbell::Wait always does) and then sees Dead().
return false;
}
--m_impl->signals;
return true;
}
void CondVarDoorbell::Kill() {
{
std::lock_guard<std::mutex> lock(m_impl->mutex);
m_dead.store(true, std::memory_order_release);
}
// notify_all, not notify_one: both a raw Park and a Doorbell::Wait may
// be parked here, and after this nobody will ring again.
m_impl->cv.notify_all();
}
void CondVarDoorbell::Reset() {
std::lock_guard<std::mutex> lock(m_impl->mutex);
m_impl->signals = 0;
}
#if !defined(_WIN32)
// -----------------------------------------------------------------------
// SocketDoorbell
// -----------------------------------------------------------------------
SocketDoorbell::SocketDoorbell(int fd, std::uint8_t code, bool ownsFd)
: m_fd(fd), m_code(code), m_ownsFd(ownsFd) {
#if defined(SO_NOSIGPIPE)
// The per-socket form of MSG_NOSIGNAL, on the platforms that lack the per-call one:
// a Notify to a hung-up peer must come back as EPIPE, not as a fatal signal.
if (m_fd >= 0) {
const int one = 1;
(void)::setsockopt(m_fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
}
#endif
}
SocketDoorbell::~SocketDoorbell() {
if (m_ownsFd && m_fd >= 0) {
::close(m_fd);
}
}
void SocketDoorbell::Notify() {
if (m_fd < 0) {
return;
}
const std::uint8_t byte = m_code;
for (;;) {
const ssize_t written = ::send(m_fd, &byte, 1, MSG_DONTWAIT | MSG_NOSIGNAL);
if (written == 1) {
return;
}
if (written < 0 && errno == EINTR) {
continue;
}
if (written < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
// The socket buffer already holds unread wakeups: the peer has
// one pending, which is all a doorbell promises.
return;
}
if (written < 0 && (errno == EPIPE || errno == ECONNRESET)) {
// The peer is gone: it can never ring back either, so latch it
// here too rather than waiting for a Park to discover it.
m_dead = true;
return;
}
MGLOG_D("MG_Remote doorbell: send failed (errno=%d)", errno);
return;
}
}
bool SocketDoorbell::Park(std::uint32_t timeoutMs) {
if (m_fd < 0 || m_dead) {
return false;
}
const auto start = std::chrono::steady_clock::now();
for (;;) {
int pollTimeout = -1;
if (timeoutMs != kWaitForever) {
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
const long long remaining = static_cast<long long>(timeoutMs) - elapsed;
pollTimeout = remaining <= 0 ? 0 : static_cast<int>(remaining);
}
struct pollfd pfd{};
pfd.fd = m_fd;
pfd.events = POLLIN;
const int ready = ::poll(&pfd, 1, pollTimeout);
if (ready < 0) {
if (errno == EINTR) {
continue; // a signal is not a wakeup; keep the deadline
}
MGLOG_D("MG_Remote doorbell: poll failed (errno=%d)", errno);
return false;
}
if (ready == 0) {
return false; // timed out
}
// revents has to be inspected, not just `ready > 0`. Once the peer
// closes its end the descriptor is permanently poll-ready with
// nothing to read (measured on Linux: revents=POLLIN|POLLHUP,
// recv()==0), so treating any readiness as a wakeup turns every
// park on a dead peer into a 100% CPU spin - unbounded, because
// Doorbell::Wait re-parks until its deadline and kWaitForever has
// none.
if ((pfd.revents & (POLLERR | POLLNVAL)) != 0) {
MGLOG_D("MG_Remote doorbell: fd %d unusable (revents=0x%X)", m_fd,
static_cast<unsigned>(pfd.revents));
m_dead = true;
return false;
}
if ((pfd.revents & POLLIN) != 0) {
if (Drain() != 0) {
return true; // a real wakeup byte
}
if (m_dead) {
return false; // EOF, not an event
}
// Ready but empty and still alive: someone else drained it.
// Report the wakeup and let the caller re-test its condition.
return true;
}
if ((pfd.revents & POLLHUP) != 0) {
m_dead = true;
return false;
}
// Readiness with no bit we requested or recognise: there is
// nothing to consume and no way to make progress, so refuse to
// poll this descriptor again.
MGLOG_D("MG_Remote doorbell: fd %d ready with revents=0x%X", m_fd,
static_cast<unsigned>(pfd.revents));
m_dead = true;
return false;
}
}
std::uint64_t SocketDoorbell::Drain() {
// Level-triggered to edge-triggered: swallow every queued byte so one
// stale wakeup cannot make later Parks return without an event.
std::uint64_t consumed = 0;
std::uint8_t scratch[64];
for (;;) {
const ssize_t got = ::recv(m_fd, scratch, sizeof(scratch), MSG_DONTWAIT);
if (got > 0) {
consumed += static_cast<std::uint64_t>(got);
continue;
}
if (got == 0) {
// Orderly shutdown on a stream socket: the peer is gone and
// will never ring again.
m_dead = true;
return consumed;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
return consumed; // drained
}
MGLOG_D("MG_Remote doorbell: recv failed (errno=%d)", errno);
m_dead = true;
return consumed;
}
}
void SocketDoorbell::Reset() {
if (m_fd < 0 || m_dead) {
return;
}
(void)Drain();
}
#endif // !_WIN32
} // namespace MobileGL::MG_Remote::Transport
+268
View File
@@ -0,0 +1,268 @@
// MobileGL - MobileGL/MG_Remote/Transport/Doorbell.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 bidirectional doorbell: spin briefly, then park.
//
// Both directions exist, and that is the point (inherited design, earlier plan
// section 6.2a):
// - client -> server: the consumer spins, sets consumerParked, then blocks;
// the producer rings only when consumerParked is set.
// - server -> client: the client spins MOBILEGL_IPC_SPIN_US (default 50us),
// sets producerParked, then blocks; the server rings after advancing any
// watermark, only when producerParked is set.
// Without the second direction every client wait - present credit, a blocking
// kNeedsAck request, a full ring - degenerates into a cross-process spin on
// one shared cache line: up to a whole frame of a big core at full clock on a
// phone, fighting the GPU and the game's JVM for it. MobileGL has no affinity
// control anywhere in the tree, so it cannot even be pushed to a little core.
//
// Two implementations, no platform-specific wakeup primitive (no futex, no
// eventfd, no named event):
// - CondVarDoorbell for `inproc` (one process, two threads),
// - SocketDoorbell for `spawn` (one byte on a socket; POSIX only).
//
// The lost-wakeup window is closed by two seq_cst FENCES, not by the ordering
// of the park flag's own load and store:
// - the waiter sets the flag, executes std::atomic_thread_fence(seq_cst),
// and THEN re-tests the condition (Doorbell::Wait);
// - the notifier publishes its watermark, executes the same fence, and THEN
// reads the flag (NotifyIfParked).
// Both fences sit in the single seq_cst total order, so one precedes the
// other, and [atomics.order] then forces at least one side to observe the
// other's store. The flag's own accesses may be relaxed: they are not what
// closes the window.
//
// A seq_cst store paired with a seq_cst load would NOT be enough, which is
// why the fences are here and why neither may be removed. That Dekker
// argument needs all FOUR accesses in the total order, and the other two are
// not: the watermark publish is a release store (RingProducer::Publish) and
// the condition re-test is an acquire load. On x86 the gap is concrete rather
// than theoretical - a release store is a plain MOV that can still sit in the
// store buffer while the load of the park flag, also a plain MOV, reads 0, so
// the notifier skips the ring and the waiter parks on a stale watermark
// forever. (ARMv8 survives it only because STLR->LDAR is RCsc, i.e. by luck.)
//
// The other half of the contract is ordering between the caller and the
// fence: NotifyIfParked must be called AFTER the watermark is published. A
// fence only orders what precedes it.
#pragma once
#include <atomic>
#include <chrono>
#include <cstdint>
#if defined(__x86_64__) || defined(__i386__)
#include <immintrin.h>
#endif
namespace MobileGL::MG_Remote::Transport {
// MOBILEGL_IPC_SPIN_US default.
inline constexpr std::uint32_t kDefaultSpinUs = 50;
// Park with no deadline.
inline constexpr std::uint32_t kWaitForever = 0xFFFFFFFFu;
// Wire codes, so a shared socket can carry both directions distinguishably.
inline constexpr std::uint8_t kDoorbellRingAdvanced = 0x01; // client -> server
inline constexpr std::uint8_t kDoorbellWatermarkAdvanced = 0x02; // server -> client
inline void CpuRelax() {
#if defined(__x86_64__) || defined(__i386__)
_mm_pause();
#elif defined(__aarch64__) || defined(__arm__)
__asm__ __volatile__("yield" ::: "memory");
#else
std::atomic_signal_fence(std::memory_order_seq_cst);
#endif
}
class Doorbell {
public:
virtual ~Doorbell() = default;
Doorbell(const Doorbell&) = delete;
Doorbell& operator=(const Doorbell&) = delete;
// Wakes a parked peer. Cheap and idempotent: a wakeup that arrives when
// nobody is parked is remembered, so the next Park returns immediately
// rather than sleeping through an event that already happened.
virtual void Notify() = 0;
// Blocks until notified or the deadline passes. Returns true when a
// wakeup was consumed. timeoutMs == 0 polls; kWaitForever never times
// out.
virtual bool Park(std::uint32_t timeoutMs) = 0;
// Drops pending wakeups. Used when a waiter gives up, so a stale byte
// does not make the next Park return spuriously forever.
virtual void Reset() = 0;
// True once the wakeup channel is permanently unusable: the peer closed
// its end of the socket, or the inproc channel was shut down. A dead
// doorbell can never deliver another wakeup, and Wait must stop
// re-parking on it - for the socket because its descriptor is
// permanently poll-ready and a waiter with no deadline would burn a
// big core at full clock, for the condvar because Park would otherwise
// block forever and Shutdown could never join the waiter. Every
// implementation has a death state; the base default is only for a
// bell that cannot die.
virtual bool Dead() const { return false; }
// Spin `spinUs`, then park until `ready()` or the deadline.
// `parked` is the RingControl flag the peer tests before ringing.
template <class Ready>
bool Wait(std::atomic<std::uint32_t>& parked, Ready&& ready, std::uint32_t spinUs,
std::uint32_t timeoutMs) {
if (ready()) {
return true;
}
const auto start = std::chrono::steady_clock::now();
const auto deadline = timeoutMs == kWaitForever
? std::chrono::steady_clock::time_point::max()
: start + std::chrono::milliseconds(timeoutMs);
const auto spinEnd = start + std::chrono::microseconds(spinUs);
while (std::chrono::steady_clock::now() < spinEnd) {
if (ready()) {
return true;
}
CpuRelax();
}
for (;;) {
// Announce, FENCE, then re-test. The fence is the mechanism -
// see the file header - so setting the flag itself is relaxed.
parked.store(1, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_seq_cst);
if (ready()) {
parked.store(0, std::memory_order_relaxed);
return true;
}
const auto now = std::chrono::steady_clock::now();
if (now >= deadline) {
parked.store(0, std::memory_order_relaxed);
return ready();
}
std::uint32_t chunkMs = kWaitForever;
if (timeoutMs != kWaitForever) {
const auto remaining =
std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count();
chunkMs = remaining <= 0 ? 0 : static_cast<std::uint32_t>(remaining);
}
Park(chunkMs);
// Clearing is relaxed on purpose: a notifier that reads a
// stale 1 only rings a bell nobody is waiting on, which the
// doorbell remembers and the next Park consumes. The dangerous
// direction - a notifier reading 0 while the waiter is really
// parked - is the one the fence above rules out.
parked.store(0, std::memory_order_relaxed);
if (ready()) {
return true;
}
if (Dead()) {
// Nothing can ring this bell again and parking on it no
// longer blocks, so looping here would spin at full clock
// for as long as the caller is willing to wait - which,
// with kWaitForever, is forever.
return false;
}
if (timeoutMs != kWaitForever && std::chrono::steady_clock::now() >= deadline) {
return false;
}
}
}
protected:
Doorbell() = default;
};
// Rings `bell` only when the peer said it is parked.
//
// PRECONDITION: whatever the waiter's condition reads - the ring head, a
// sequence watermark, a queue push - is ALREADY published when this is
// called. The fence only orders what precedes it, so ringing before
// publishing reopens the window this closes. The fence pairs with the one
// in Doorbell::Wait; see the file header for why the flag's own memory
// order is not what makes this sound.
inline void NotifyIfParked(Doorbell& bell, std::atomic<std::uint32_t>& parked) {
std::atomic_thread_fence(std::memory_order_seq_cst);
if (parked.load(std::memory_order_relaxed) != 0) {
bell.Notify();
}
}
// `inproc`: one process, two threads.
class CondVarDoorbell final : public Doorbell {
public:
CondVarDoorbell();
~CondVarDoorbell() override;
void Notify() override;
bool Park(std::uint32_t timeoutMs) override;
void Reset() override;
bool Dead() const override { return m_dead.load(std::memory_order_acquire); }
// Hangs the bell up for good: every parked waiter returns false now and
// every later Park returns false at once. The inproc twin of the socket
// peer closing its end (SocketDoorbell latches m_dead on EOF), and what
// InProcessChannel::Close rings instead of Notify. A Notify is consumed
// by ONE Park; Doorbell::Wait then re-tests its condition, finds
// nothing published, finds the bell alive, and with kWaitForever parks
// again - so a Shutdown that only rang could never join a server thread
// sitting in the design's own steady state (spun, set consumerParked,
// blocked). Irreversible by design, like the socket's.
void Kill();
private:
struct Impl;
Impl* m_impl;
std::atomic<bool> m_dead{false};
};
#if !defined(_WIN32)
// `spawn`: one byte on a socket (one direction of a socketpair, or the aux
// socket). POSIX only; the Windows path will use an overlapped named pipe
// and is not part of this skeleton.
class SocketDoorbell final : public Doorbell {
public:
// `fd` must be one end of an AF_UNIX socket pair, not a pipe: Notify
// uses send() with MSG_DONTWAIT|MSG_NOSIGNAL and Park uses
// poll()+recv(), which a pipe end refuses with ENOTSOCK. Prefer
// SOCK_STREAM for the spawn transport - measured on Linux, a closed
// peer makes a stream end report POLLIN|POLLHUP with recv()==0, which
// is how death is detected, while a SOCK_DGRAM end reports no
// readiness at all and a waiter with no deadline would simply hang.
// When `ownsFd` the descriptor is closed with this object. `code` is
// the byte written by Notify.
SocketDoorbell(int fd, std::uint8_t code, bool ownsFd);
~SocketDoorbell() override;
void Notify() override;
bool Park(std::uint32_t timeoutMs) override;
void Reset() override;
bool Dead() const override { return m_dead; }
int Fd() const { return m_fd; }
private:
// Consumes every queued wakeup byte and returns how many. Latches
// m_dead on EOF: recv returning 0 on a stream socket is the peer's
// hangup, not a wakeup, and the descriptor stays poll-ready forever
// afterwards.
std::uint64_t Drain();
int m_fd;
std::uint8_t m_code;
bool m_ownsFd;
bool m_dead = false;
};
#endif
} // namespace MobileGL::MG_Remote::Transport
+323
View File
@@ -0,0 +1,323 @@
// MobileGL - MobileGL/MG_Remote/Transport/FdPassing.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 "FdPassing.h"
#include <MG_Util/Debug/Log.h>
#include <chrono>
#include <cstring>
#if !defined(_WIN32)
#include <cerrno>
#include <fcntl.h>
#include <poll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#endif
// MSG_NOSIGNAL is Linux (and Android). macOS and the BSDs spell the same protection as the
// SO_NOSIGPIPE socket option, set once per socket at creation (CreateSocketPair below, and
// SocketDoorbell's constructor). With neither, a write to a hung-up peer raises SIGPIPE and
// kills the process instead of returning EPIPE.
#if !defined(_WIN32) && !defined(MSG_NOSIGNAL)
#define MSG_NOSIGNAL 0
#endif
namespace MobileGL::MG_Remote::Transport::FdPassing {
#if defined(_WIN32)
bool Supported() { return false; }
MobileGLResult CreateSocketPair(int[2]) { return MOBILEGL_ERR_UNSUPPORTED; }
MobileGLResult SendFd(int, int, MobileGLByteSpan) { return MOBILEGL_ERR_UNSUPPORTED; }
MobileGLResult ReceiveFd(int, int*, MobileGLMutableByteSpan, std::uint64_t*, std::uint32_t) {
return MOBILEGL_ERR_UNSUPPORTED;
}
#else
namespace {
// Every datagram starts with this, so the sideband length is explicit
// and a stray datagram is recognisable.
struct SidebandHeader {
std::uint32_t magic;
std::uint32_t sidebandSize;
};
constexpr std::uint32_t kSidebandMagic = 0x4446474Du; // 'MGFD' on the wire
int WaitReadable(int socket, std::uint32_t timeoutMs) {
const auto start = std::chrono::steady_clock::now();
for (;;) {
int pollTimeout = -1;
if (timeoutMs != 0xFFFFFFFFu) {
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
const long long remaining = static_cast<long long>(timeoutMs) - elapsed;
pollTimeout = remaining <= 0 ? 0 : static_cast<int>(remaining);
}
struct pollfd pfd{};
pfd.fd = socket;
pfd.events = POLLIN;
const int ready = ::poll(&pfd, 1, pollTimeout);
if (ready < 0 && errno == EINTR) {
continue;
}
return ready;
}
}
} // namespace
bool Supported() { return true; }
MobileGLResult CreateSocketPair(int outFds[2]) {
if (outFds == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
int fds[2] = {-1, -1};
int type = SOCK_DGRAM;
#if defined(SOCK_CLOEXEC)
type |= SOCK_CLOEXEC;
#endif
if (::socketpair(AF_UNIX, type, 0, fds) != 0) {
MGLOG_E("MG_Remote fd passing: socketpair failed (errno=%d)", errno);
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
#if defined(SO_NOSIGPIPE)
// The per-socket form of MSG_NOSIGNAL, on the platforms that lack the per-call one.
for (int fd : fds) {
const int one = 1;
(void)::setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, sizeof(one));
}
#endif
outFds[0] = fds[0];
outFds[1] = fds[1];
return MOBILEGL_OK;
}
MobileGLResult SendFd(int socket, int fd, MobileGLByteSpan sideband) {
if (socket < 0 || fd < 0) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
if (sideband.size > kMaxSidebandBytes || (sideband.size != 0 && sideband.data == nullptr)) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
std::uint8_t payload[sizeof(SidebandHeader) + kMaxSidebandBytes];
SidebandHeader header{};
header.magic = kSidebandMagic;
header.sidebandSize = static_cast<std::uint32_t>(sideband.size);
std::memcpy(payload, &header, sizeof(header));
if (sideband.size != 0) {
std::memcpy(payload + sizeof(header), sideband.data,
static_cast<std::size_t>(sideband.size));
}
const std::size_t payloadSize = sizeof(header) + static_cast<std::size_t>(sideband.size);
struct iovec iov{};
iov.iov_base = payload;
iov.iov_len = payloadSize;
// CMSG_SPACE, not sizeof: the control buffer has to hold the aligned
// cmsghdr as well as the descriptor.
union {
struct cmsghdr align;
char bytes[CMSG_SPACE(sizeof(int))];
} control{};
std::memset(&control, 0, sizeof(control));
struct msghdr msg{};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control.bytes;
msg.msg_controllen = sizeof(control.bytes);
struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
std::memcpy(CMSG_DATA(cmsg), &fd, sizeof(fd));
for (;;) {
const ssize_t sent = ::sendmsg(socket, &msg, MSG_NOSIGNAL);
if (sent >= 0) {
if (static_cast<std::size_t>(sent) != payloadSize) {
// A datagram socket sends all or nothing.
MGLOG_E("MG_Remote fd passing: short datagram (%zd of %zu bytes)", sent,
payloadSize);
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
return MOBILEGL_OK;
}
if (errno == EINTR) {
continue;
}
if (errno == EPIPE || errno == ECONNRESET) {
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
MGLOG_E("MG_Remote fd passing: sendmsg failed (errno=%d)", errno);
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
}
MobileGLResult ReceiveFd(int socket, int* outFd, MobileGLMutableByteSpan sideband,
std::uint64_t* outSidebandSize, std::uint32_t timeoutMs) {
if (socket < 0 || outFd == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
*outFd = -1;
if (outSidebandSize != nullptr) {
*outSidebandSize = 0;
}
// Checked before the recvmsg: a datagram cannot be partially consumed,
// so a too-small destination must never cost us the descriptor.
if (sideband.size < kMaxSidebandBytes) {
if (outSidebandSize != nullptr) {
*outSidebandSize = kMaxSidebandBytes;
}
return MOBILEGL_ERR_BUFFER_TOO_SMALL;
}
if (sideband.data == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
const int ready = WaitReadable(socket, timeoutMs);
if (ready < 0) {
MGLOG_E("MG_Remote fd passing: poll failed (errno=%d)", errno);
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
if (ready == 0) {
return MOBILEGL_ERR_TIMEOUT;
}
std::uint8_t payload[sizeof(SidebandHeader) + kMaxSidebandBytes];
struct iovec iov{};
iov.iov_base = payload;
iov.iov_len = sizeof(payload);
union {
struct cmsghdr align;
char bytes[CMSG_SPACE(sizeof(int) * 4)];
} control{};
std::memset(&control, 0, sizeof(control));
struct msghdr msg{};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = control.bytes;
msg.msg_controllen = sizeof(control.bytes);
ssize_t got = 0;
for (;;) {
int flags = 0;
#if defined(MSG_CMSG_CLOEXEC)
flags |= MSG_CMSG_CLOEXEC;
#endif
got = ::recvmsg(socket, &msg, flags);
if (got >= 0) {
break;
}
if (errno == EINTR) {
continue;
}
if (errno == ECONNRESET) {
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
MGLOG_E("MG_Remote fd passing: recvmsg failed (errno=%d)", errno);
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
if (got == 0) {
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
// Collect every descriptor first, so an unexpected extra one is closed
// rather than leaked, whatever else is wrong with the message.
int received[4];
int receivedCount = 0;
for (struct cmsghdr* cmsg = CMSG_FIRSTHDR(&msg); cmsg != nullptr;
cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level != SOL_SOCKET || cmsg->cmsg_type != SCM_RIGHTS) {
continue;
}
const std::size_t bytes = cmsg->cmsg_len - CMSG_LEN(0);
const int count = static_cast<int>(bytes / sizeof(int));
for (int i = 0; i < count && receivedCount < 4; ++i) {
int fd = -1;
std::memcpy(&fd, CMSG_DATA(cmsg) + i * sizeof(int), sizeof(fd));
received[receivedCount++] = fd;
}
}
#if !defined(MSG_CMSG_CLOEXEC)
// No atomic close-on-exec on receive here (macOS, the BSDs): set it by hand on every
// descriptor that arrived, before anything else can fork. The window between the
// recvmsg and this loop is the platform's, not ours; leaving the flag off altogether
// would hand every shared segment to every child the process ever spawns.
for (int i = 0; i < receivedCount; ++i) {
if (received[i] >= 0) {
(void)::fcntl(received[i], F_SETFD, FD_CLOEXEC);
}
}
#endif
const auto closeAll = [&](int keepIndex) {
for (int i = 0; i < receivedCount; ++i) {
if (i != keepIndex && received[i] >= 0) {
::close(received[i]);
}
}
};
if ((msg.msg_flags & MSG_CTRUNC) != 0) {
// The kernel dropped ancillary data: whatever arrived is not a
// complete offer, and silently continuing would hand the caller a
// half-transferred segment.
MGLOG_E("MG_Remote fd passing: ancillary data truncated; the descriptor did not "
"arrive intact");
closeAll(-1);
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
if (receivedCount != 1) {
MGLOG_E("MG_Remote fd passing: expected exactly one descriptor, got %d", receivedCount);
closeAll(-1);
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
if (static_cast<std::size_t>(got) < sizeof(SidebandHeader)) {
MGLOG_E("MG_Remote fd passing: %zd byte datagram is shorter than the header", got);
closeAll(-1);
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
SidebandHeader header{};
std::memcpy(&header, payload, sizeof(header));
if (header.magic != kSidebandMagic ||
header.sidebandSize > kMaxSidebandBytes ||
sizeof(SidebandHeader) + header.sidebandSize != static_cast<std::size_t>(got)) {
MGLOG_E("MG_Remote fd passing: bad sideband header (magic=0x%08X size=%u datagram=%zd)",
header.magic, header.sidebandSize, got);
closeAll(-1);
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
if (header.sidebandSize != 0) {
std::memcpy(sideband.data, payload + sizeof(SidebandHeader), header.sidebandSize);
}
if (outSidebandSize != nullptr) {
*outSidebandSize = header.sidebandSize;
}
*outFd = received[0];
closeAll(0);
return MOBILEGL_OK;
}
#endif // _WIN32
} // namespace MobileGL::MG_Remote::Transport::FdPassing
+67
View File
@@ -0,0 +1,67 @@
// MobileGL - MobileGL/MG_Remote/Transport/FdPassing.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
// SCM_RIGHTS descriptor passing over an AF_UNIX socket pair. POSIX only.
//
// This is the FIRST transport commit, deliberately (inherited design, plan
// section 8.1, "SCM_RIGHTS must be implemented in the first transport
// commit"). The earlier branch pushed it to a later phase and hardcoded
// `out->fd = -1` in its offer poll, so on the only platform that matters its
// data plane could never move a byte: every segment announcement resolved to
// "no descriptor". A transport whose shm cannot cross the process boundary is
// not a transport.
//
// Channel shape: a dedicated AF_UNIX SOCK_DGRAM socketpair, NOT the control
// byte stream. Two reasons:
// - SOCK_DGRAM preserves message boundaries on every POSIX (SOCK_SEQPACKET
// does not exist on macOS), so one sendmsg is exactly one recvmsg and the
// ancillary data can never be split away from its payload;
// - ancillary data attached to a byte stream binds to whichever ordinary
// byte happens to be at the front of the reader's buffer, which is
// unmanageable once frames are being reassembled.
#pragma once
#include "../Protocol/mg_protocol_base.h"
#include <cstdint>
namespace MobileGL::MG_Remote::Transport::FdPassing {
// Upper bound for the bytes that travel with a descriptor (a SegmentRef
// sized announcement, not payload).
inline constexpr std::uint64_t kMaxSidebandBytes = 256;
// False on platforms without SCM_RIGHTS (Windows).
bool Supported();
// Creates the aux socket pair. Both descriptors are CLOEXEC and owned by
// the caller. outFds[0] is conventionally the client end, [1] the server's
// (the one that is inherited or passed to the spawned process).
MobileGLResult CreateSocketPair(int outFds[2]);
// Sends `fd` with `sideband` attached. The caller keeps ownership of `fd`
// (the peer gets its own descriptor for the same open file description).
// sideband.size must be <= kMaxSidebandBytes.
MobileGLResult SendFd(int socket, int fd, MobileGLByteSpan sideband);
// Receives one descriptor and its sideband bytes.
//
// `sideband` must be at least kMaxSidebandBytes: a datagram cannot be
// partially consumed, so the capacity is checked BEFORE anything is read.
// A short buffer returns MOBILEGL_ERR_BUFFER_TOO_SMALL with
// *outSidebandSize = kMaxSidebandBytes and consumes nothing, so no
// descriptor is ever dropped on the floor.
//
// On success *outFd owns a descriptor this process must close.
// MOBILEGL_ERR_TIMEOUT when nothing arrived (timeoutMs 0 = poll),
// MOBILEGL_ERR_TRANSPORT_CLOSED on peer close.
MobileGLResult ReceiveFd(int socket, int* outFd, MobileGLMutableByteSpan sideband,
std::uint64_t* outSidebandSize, std::uint32_t timeoutMs);
} // namespace MobileGL::MG_Remote::Transport::FdPassing
+208
View File
@@ -0,0 +1,208 @@
// MobileGL - MobileGL/MG_Remote/Transport/Framing.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
// Control-channel wire framing: [u32 magic 'MGLF'][u32 payloadLength][payload].
// Length excludes the 8-byte header and is capped at 64 MiB.
//
// Two defects of the earlier branch's codec are fixed here, and both are the
// reason this file is not a copy of it:
//
// 1. Its Feed() unconditionally returned OK and its header peek merely
// returned false on a bad magic or an oversized length. A corrupt or
// desynchronized stream therefore turned into a silent, permanent hang -
// the reader kept waiting for a message that could never be parsed, with
// no error anywhere. Here a violation latches a failed state, is logged at
// ERROR, and every later call returns MOBILEGL_ERR_PROTOCOL_MISMATCH.
//
// 2. Its receive path failed the call and consumed the message when the
// caller's buffer was too small, wedging the stream. Here
// MOBILEGL_ERR_BUFFER_TOO_SMALL reports the required size and KEEPS the
// message queued.
//
// The reader is a plain byte-stream reassembler: it never assumes a read()
// returned a whole frame.
#pragma once
#include "../Protocol/mg_protocol_base.h"
// NOT <MG_Util/Debug/Log.h>: that header pulls the GL frontend's umbrella into
// every translation unit that reassembles a frame. See WireLog.h.
#include "WireLog.h"
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
namespace MobileGL::MG_Remote::Transport {
// 'MGLF', little-endian on the wire (both ends are the same machine).
inline constexpr std::uint32_t kFrameMagic = 0x464C474Du;
inline constexpr std::uint64_t kFrameHeaderSize = 8;
inline constexpr std::uint64_t kMaxFramePayloadSize = 64ull * 1024 * 1024;
// Compaction threshold: consumed bytes are dropped from the front once
// enough of them accumulate, so a long-lived reader neither memmoves per
// message nor grows without bound.
inline constexpr std::uint64_t kFrameReaderCompactThreshold = 64ull * 1024;
// Appends one framed message to `out`.
inline MobileGLResult AppendFrame(std::vector<std::uint8_t>& out, const void* payload,
std::uint64_t size) {
if (size > kMaxFramePayloadSize) {
WireLogError("MG_Remote framing: refusing to send a %llu byte payload (cap %llu); "
"bulk bytes belong in shm",
static_cast<unsigned long long>(size),
static_cast<unsigned long long>(kMaxFramePayloadSize));
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
if (size != 0 && payload == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
std::uint8_t header[kFrameHeaderSize];
const std::uint32_t magic = kFrameMagic;
const std::uint32_t length = static_cast<std::uint32_t>(size);
std::memcpy(header + 0, &magic, sizeof(magic));
std::memcpy(header + 4, &length, sizeof(length));
out.insert(out.end(), header, header + kFrameHeaderSize);
const auto* bytes = static_cast<const std::uint8_t*>(payload);
out.insert(out.end(), bytes, bytes + size);
return MOBILEGL_OK;
}
// Incremental frame extractor over a raw byte stream.
class FrameReader {
public:
// Feeds raw stream bytes. Validates the frame header the moment enough
// bytes for one exist - a bad magic or an oversized length is reported
// here, not swallowed.
MobileGLResult Feed(const void* data, std::uint64_t size) {
if (m_failed) {
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
if (size != 0) {
if (data == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
const auto* bytes = static_cast<const std::uint8_t*>(data);
m_buffer.insert(m_buffer.end(), bytes, bytes + size);
}
return ParseHeader();
}
bool Failed() const { return m_failed; }
bool HasMessage() const {
return !m_failed && m_haveHeader && Available() >= kFrameHeaderSize + m_pendingSize;
}
// Size of the next complete message, or 0 when none is complete yet.
std::uint64_t PendingMessageSize() const { return HasMessage() ? m_pendingSize : 0; }
std::uint64_t BufferedBytes() const { return Available(); }
// Copies the next complete message out.
// MOBILEGL_OK - copied, *outSize set, message consumed
// MOBILEGL_ERR_BUFFER_TOO_SMALL - *outSize = required size, message KEPT
// MOBILEGL_ERR_TIMEOUT - no complete message buffered
// MOBILEGL_ERR_PROTOCOL_MISMATCH- the stream is latched failed
MobileGLResult TakeMessage(MobileGLMutableByteSpan buffer, std::uint64_t* outSize) {
if (m_failed) {
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
if (!HasMessage()) {
return MOBILEGL_ERR_TIMEOUT;
}
if (outSize != nullptr) {
*outSize = m_pendingSize;
}
if (buffer.size < m_pendingSize) {
// The message stays queued; the caller retries with a big
// enough buffer.
return MOBILEGL_ERR_BUFFER_TOO_SMALL;
}
if (m_pendingSize != 0) {
if (buffer.data == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
std::memcpy(buffer.data, m_buffer.data() + m_readPos + kFrameHeaderSize,
static_cast<std::size_t>(m_pendingSize));
}
Consume();
return MOBILEGL_OK;
}
// Convenience overload that sizes the destination itself.
MobileGLResult TakeMessage(std::vector<std::uint8_t>& out) {
if (m_failed) {
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
if (!HasMessage()) {
return MOBILEGL_ERR_TIMEOUT;
}
const auto* first = m_buffer.data() + m_readPos + kFrameHeaderSize;
out.assign(first, first + m_pendingSize);
Consume();
return MOBILEGL_OK;
}
private:
std::uint64_t Available() const { return m_buffer.size() - m_readPos; }
MobileGLResult ParseHeader() {
if (m_haveHeader || Available() < kFrameHeaderSize) {
return MOBILEGL_OK;
}
std::uint32_t magic = 0;
std::uint32_t length = 0;
std::memcpy(&magic, m_buffer.data() + m_readPos, sizeof(magic));
std::memcpy(&length, m_buffer.data() + m_readPos + 4, sizeof(length));
if (magic != kFrameMagic) {
m_failed = true;
WireLogError("MG_Remote framing: bad frame magic 0x%08X (expected 0x%08X); the "
"control stream is desynchronized and this transport is now dead",
magic, kFrameMagic);
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
if (length > kMaxFramePayloadSize) {
m_failed = true;
WireLogError("MG_Remote framing: frame length %u exceeds the %llu byte cap; "
"refusing to allocate on a peer-supplied length",
length, static_cast<unsigned long long>(kMaxFramePayloadSize));
return MOBILEGL_ERR_PROTOCOL_MISMATCH;
}
m_pendingSize = length;
m_haveHeader = true;
return MOBILEGL_OK;
}
void Consume() {
m_readPos += kFrameHeaderSize + m_pendingSize;
m_pendingSize = 0;
m_haveHeader = false;
if (m_readPos == m_buffer.size()) {
m_buffer.clear();
m_readPos = 0;
} else if (m_readPos >= kFrameReaderCompactThreshold) {
m_buffer.erase(m_buffer.begin(),
m_buffer.begin() + static_cast<std::ptrdiff_t>(m_readPos));
m_readPos = 0;
}
// Header of the next message may already be buffered.
(void)ParseHeader();
}
std::vector<std::uint8_t> m_buffer;
std::uint64_t m_readPos = 0;
std::uint64_t m_pendingSize = 0;
bool m_haveHeader = false;
bool m_failed = false;
};
} // namespace MobileGL::MG_Remote::Transport
+130
View File
@@ -0,0 +1,130 @@
// MobileGL - MobileGL/MG_Remote/Transport/ITransport.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 control-plane transport interface.
//
// It is deliberately dumb: complete messages in, complete messages out, plus
// the one thing shared memory cannot do without help - handing a file
// descriptor to the peer. No session routing, no seq accounting, no
// serialization; those live above, in the protocol layer.
//
// Everything on the hot path bypasses this interface entirely: records go into
// the SEG_CMD ring (Ring.h) and the peer is woken through a Doorbell
// (Doorbell.h). ITransport carries the handshake, surface ops, resync, aux
// requests and fatals - the rare, variable-length, must-evolve traffic that
// plan section 7.1 assigns to FlatBuffers tables.
//
// This header stays dependency-light on purpose (mg_protocol_base.h plus the
// standard library): it is included by both roles and by the eventual
// server-side binary, and nothing about a byte pipe needs the GL frontend's
// umbrella header.
//
// Threading: one instance is not internally synchronized for send; callers
// serialize sends. ReceiveFrame/ReceiveFd may be called from one dedicated
// reader thread concurrently with sends from another.
#pragma once
#include "../Protocol/mg_protocol_base.h"
#include <cstdint>
namespace MobileGL::MG_Remote::Transport {
// Which end of the connection this instance is.
enum class TransportRole : std::uint32_t {
Server = 1, // accepts the client connection
Client = 2, // connects to the server endpoint
InProcess = 3, // same-process hand-off (CI / inproc delivery mode)
};
class ITransport {
public:
virtual ~ITransport() = default;
ITransport(const ITransport&) = delete;
ITransport& operator=(const ITransport&) = delete;
// ---- control plane -------------------------------------------------
// Sends one complete message. `bytes` is borrowed: the implementation
// either copies it or completes the underlying write before returning.
// A payload larger than Framing::kMaxFramePayloadSize is rejected with
// MOBILEGL_ERR_INVALID_ARGUMENT - bulk bytes belong in shm, never here.
virtual MobileGLResult SendFrame(MobileGLByteSpan bytes) = 0;
// Receives the next complete message.
//
// MOBILEGL_OK - copied into `buffer`, *outSize is
// the message size, message consumed.
// MOBILEGL_ERR_BUFFER_TOO_SMALL - `buffer` is too small. *outSize is
// the size required and THE MESSAGE
// STAYS QUEUED: call again with a
// buffer of at least that size and it
// is still there.
// MOBILEGL_ERR_TIMEOUT - nothing arrived within timeoutMs
// (0 = non-blocking poll).
// MOBILEGL_ERR_TRANSPORT_CLOSED - peer gone, nothing left buffered.
// MOBILEGL_ERR_PROTOCOL_MISMATCH- framing violated; the transport is
// latched failed and never recovers.
//
// The buffer-too-small half of that contract is the whole point of
// having one: the earlier branch's transport failed the call AND
// dropped the message, which wedges the stream permanently the first
// time a message is bigger than the reader's guess.
virtual MobileGLResult ReceiveFrame(MobileGLMutableByteSpan buffer, std::uint64_t* outSize,
std::uint32_t timeoutMs) = 0;
// Size of the next pending message, or 0 when none is buffered. Lets a
// caller size its buffer without a failed receive first.
virtual std::uint64_t PeekFrameSize() = 0;
// ---- descriptor passing --------------------------------------------
// Hands `fd` to the peer. POSIX: SCM_RIGHTS over the aux socket (see
// FdPassing.h). Windows: not applicable, returns
// MOBILEGL_ERR_UNSUPPORTED - the section name travels inside SegmentRef
// instead. The caller keeps ownership of `fd` and closes it itself.
//
// This is a first-class member of the interface, not a later phase: the
// earlier branch deferred it and hardcoded `out->fd = -1` in its offer
// poll, so its data plane could not move a single byte on the only
// platform that matters.
virtual MobileGLResult ShareFd(int fd, MobileGLByteSpan sideband) = 0;
// Receives one fd previously shared by the peer. On success *outFd owns
// a descriptor this process must close. `sideband` receives the bytes
// that travelled with it (may be empty) and must be at least
// FdPassing::kMaxSidebandBytes: an fd offer is one datagram and cannot
// be half-consumed, so the capacity is checked BEFORE anything is read
// and a short buffer returns MOBILEGL_ERR_BUFFER_TOO_SMALL with the
// required size, having consumed nothing and dropped no descriptor.
virtual MobileGLResult ReceiveFd(int* outFd, MobileGLMutableByteSpan sideband,
std::uint64_t* outSidebandSize, std::uint32_t timeoutMs) = 0;
// ---- lifecycle ------------------------------------------------------
// Idempotent. Tears down the WHOLE connection, not just this end:
// both directions are half-closed, so after either endpoint calls it
// neither side can send any more (SendFrame returns
// MOBILEGL_ERR_TRANSPORT_CLOSED) and every waiter on either side is
// unblocked. That is what closing a socket does, and the spawn
// transport behaves the same way, so a one-sided contract here would
// be a promise only the in-process implementation could keep.
//
// Messages already queued stay readable until drained: a peer that
// shuts down right after sending does not lose its last message.
virtual void Shutdown() = 0;
virtual TransportRole Role() const = 0;
protected:
ITransport() = default;
};
} // namespace MobileGL::MG_Remote::Transport
@@ -0,0 +1,293 @@
// MobileGL - MobileGL/MG_Remote/Transport/InProcessTransport.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 "InProcessTransport.h"
#include "FdPassing.h"
#include "Framing.h"
#include <MG_Util/Debug/Log.h>
#include <cerrno>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <deque>
#include <mutex>
#include <vector>
#if !defined(_WIN32)
#include <unistd.h>
#endif
namespace MobileGL::MG_Remote::Transport {
namespace {
struct FdOffer {
int fd = -1;
std::vector<std::uint8_t> sideband;
};
} // namespace
// One direction of the channel: everything queued FOR one endpoint.
class InProcessChannel {
public:
struct Direction {
std::mutex mutex;
// One variable per predicate. A single cv signalled with
// notify_one would let a SendFrame's wakeup land on a thread
// blocked in ReceiveFd, which re-tests its own predicate and goes
// straight back to sleep - leaving a queued message undelivered
// until some unrelated later event. ITransport narrows the
// contract to one dedicated reader thread, but a comment is not a
// reason to ship a primitive that breaks the moment someone
// splits the reader.
std::condition_variable cv; // messages
std::condition_variable fdCv; // fdOffers
std::deque<std::vector<std::uint8_t>> messages;
std::deque<FdOffer> fdOffers;
bool closed = false;
};
~InProcessChannel() {
for (Direction& dir : m_directions) {
for (FdOffer& offer : dir.fdOffers) {
#if !defined(_WIN32)
if (offer.fd >= 0) {
::close(offer.fd);
}
#endif
}
dir.fdOffers.clear();
}
}
Direction& Inbox(int endpoint) { return m_directions[endpoint]; }
Direction& Outbox(int endpoint) { return m_directions[1 - endpoint]; }
CondVarDoorbell& Bell(int endpoint) { return m_bells[endpoint]; }
void Close() {
for (Direction& dir : m_directions) {
{
std::lock_guard<std::mutex> lock(dir.mutex);
dir.closed = true;
}
dir.cv.notify_all();
dir.fdCv.notify_all();
}
// Anything parked on a ring doorbell has to come back too, or a
// shutdown mid-frame hangs the peer forever. Kill, not Notify: a
// ring is consumed by one Park, after which Doorbell::Wait re-tests
// a condition nothing published and - the bell still reporting
// alive - parks again, with no deadline forever. Only Dead() ends
// that loop.
for (CondVarDoorbell& bell : m_bells) {
bell.Kill();
}
}
private:
Direction m_directions[2];
CondVarDoorbell m_bells[2];
};
InProcessTransport::InProcessTransport(std::shared_ptr<InProcessChannel> channel, int endpoint)
: m_channel(std::move(channel)), m_endpoint(endpoint) {}
InProcessTransport::~InProcessTransport() = default;
void InProcessTransport::CreatePair(std::unique_ptr<InProcessTransport>& outClient,
std::unique_ptr<InProcessTransport>& outServer) {
auto channel = std::make_shared<InProcessChannel>();
outClient.reset(new InProcessTransport(channel, 0));
outServer.reset(new InProcessTransport(channel, 1));
}
MobileGLResult InProcessTransport::SendFrame(MobileGLByteSpan bytes) {
if (bytes.size != 0 && bytes.data == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
// Same cap as the byte-stream transports, so nothing legal here becomes
// illegal the day the delivery mode changes to `spawn`.
if (bytes.size > kMaxFramePayloadSize) {
MGLOG_E("MG_Remote inproc: refusing a %llu byte message (cap %llu)",
static_cast<unsigned long long>(bytes.size),
static_cast<unsigned long long>(kMaxFramePayloadSize));
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
InProcessChannel::Direction& dir = m_channel->Outbox(m_endpoint);
{
std::lock_guard<std::mutex> lock(dir.mutex);
if (dir.closed) {
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
const auto* first = static_cast<const std::uint8_t*>(bytes.data);
dir.messages.emplace_back(first, first + bytes.size);
}
dir.cv.notify_one();
return MOBILEGL_OK;
}
MobileGLResult InProcessTransport::ReceiveFrame(MobileGLMutableByteSpan buffer,
std::uint64_t* outSize,
std::uint32_t timeoutMs) {
if (outSize != nullptr) {
*outSize = 0;
}
InProcessChannel::Direction& dir = m_channel->Inbox(m_endpoint);
std::unique_lock<std::mutex> lock(dir.mutex);
if (dir.messages.empty() && !dir.closed && timeoutMs != 0) {
const auto ready = [&dir] { return !dir.messages.empty() || dir.closed; };
if (timeoutMs == kWaitForever) {
dir.cv.wait(lock, ready);
} else {
dir.cv.wait_for(lock, std::chrono::milliseconds(timeoutMs), ready);
}
}
if (dir.messages.empty()) {
// Queued messages outlive the peer's Shutdown; only an empty inbox
// is a closed one.
return dir.closed ? MOBILEGL_ERR_TRANSPORT_CLOSED : MOBILEGL_ERR_TIMEOUT;
}
const std::vector<std::uint8_t>& front = dir.messages.front();
const std::uint64_t size = front.size();
if (outSize != nullptr) {
*outSize = size;
}
if (buffer.size < size) {
// Contract: the message STAYS QUEUED. The earlier branch's
// transport failed the call and popped the message anyway, which
// wedges the stream permanently the first time a reader guesses the
// size wrong.
return MOBILEGL_ERR_BUFFER_TOO_SMALL;
}
if (size != 0) {
if (buffer.data == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
std::memcpy(buffer.data, front.data(), static_cast<std::size_t>(size));
}
dir.messages.pop_front();
return MOBILEGL_OK;
}
std::uint64_t InProcessTransport::PeekFrameSize() {
InProcessChannel::Direction& dir = m_channel->Inbox(m_endpoint);
std::lock_guard<std::mutex> lock(dir.mutex);
return dir.messages.empty() ? 0 : dir.messages.front().size();
}
MobileGLResult InProcessTransport::ShareFd(int fd, MobileGLByteSpan sideband) {
#if defined(_WIN32)
(void)fd;
(void)sideband;
return MOBILEGL_ERR_UNSUPPORTED;
#else
if (fd < 0) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
if (sideband.size > FdPassing::kMaxSidebandBytes ||
(sideband.size != 0 && sideband.data == nullptr)) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
// Same ownership rule as SCM_RIGHTS: the peer gets its own descriptor
// for the same open file description and the caller keeps its own.
const int duplicate = ::dup(fd);
if (duplicate < 0) {
MGLOG_E("MG_Remote inproc: dup failed (errno=%d)", errno);
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
FdOffer offer;
offer.fd = duplicate;
if (sideband.size != 0) {
const auto* first = static_cast<const std::uint8_t*>(sideband.data);
offer.sideband.assign(first, first + sideband.size);
}
InProcessChannel::Direction& dir = m_channel->Outbox(m_endpoint);
{
std::lock_guard<std::mutex> lock(dir.mutex);
if (dir.closed) {
::close(duplicate);
return MOBILEGL_ERR_TRANSPORT_CLOSED;
}
dir.fdOffers.push_back(std::move(offer));
}
dir.fdCv.notify_one();
return MOBILEGL_OK;
#endif
}
MobileGLResult InProcessTransport::ReceiveFd(int* outFd, MobileGLMutableByteSpan sideband,
std::uint64_t* outSidebandSize,
std::uint32_t timeoutMs) {
if (outFd == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
*outFd = -1;
if (outSidebandSize != nullptr) {
*outSidebandSize = 0;
}
#if defined(_WIN32)
(void)sideband;
(void)timeoutMs;
return MOBILEGL_ERR_UNSUPPORTED;
#else
// Symmetric with FdPassing::ReceiveFd so callers behave identically in
// both delivery modes.
if (sideband.size < FdPassing::kMaxSidebandBytes) {
if (outSidebandSize != nullptr) {
*outSidebandSize = FdPassing::kMaxSidebandBytes;
}
return MOBILEGL_ERR_BUFFER_TOO_SMALL;
}
if (sideband.data == nullptr) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
InProcessChannel::Direction& dir = m_channel->Inbox(m_endpoint);
std::unique_lock<std::mutex> lock(dir.mutex);
if (dir.fdOffers.empty() && !dir.closed && timeoutMs != 0) {
const auto ready = [&dir] { return !dir.fdOffers.empty() || dir.closed; };
if (timeoutMs == kWaitForever) {
dir.fdCv.wait(lock, ready);
} else {
dir.fdCv.wait_for(lock, std::chrono::milliseconds(timeoutMs), ready);
}
}
if (dir.fdOffers.empty()) {
return dir.closed ? MOBILEGL_ERR_TRANSPORT_CLOSED : MOBILEGL_ERR_TIMEOUT;
}
FdOffer offer = std::move(dir.fdOffers.front());
dir.fdOffers.pop_front();
if (!offer.sideband.empty()) {
std::memcpy(sideband.data, offer.sideband.data(), offer.sideband.size());
}
if (outSidebandSize != nullptr) {
*outSidebandSize = offer.sideband.size();
}
*outFd = offer.fd;
return MOBILEGL_OK;
#endif
}
// Whole-connection teardown, as ITransport::Shutdown documents: both
// directions are half-closed and both ring doorbells are KILLED, because a
// peer parked on a ring doorbell mid-frame would otherwise never come back
// (a mere ring is consumed once and the waiter parks again).
void InProcessTransport::Shutdown() { m_channel->Close(); }
Doorbell& InProcessTransport::PeerDoorbell() { return m_channel->Bell(1 - m_endpoint); }
Doorbell& InProcessTransport::SelfDoorbell() { return m_channel->Bell(m_endpoint); }
} // namespace MobileGL::MG_Remote::Transport
@@ -0,0 +1,77 @@
// MobileGL - MobileGL/MG_Remote/Transport/InProcessTransport.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 `inproc` transport: two in-memory message queues and a pair of condvar
// doorbells, one connected endpoint at each end.
//
// It is not a test double. `inproc` is a delivery mode of its own - the server
// side is the monolith's own render thread, which is the single largest CPU
// lever this project has, and it is also the CI form of the split build. What
// it does NOT exercise is serialization of the byte stream, so the framing
// codec is covered separately by FramingTest.
//
// It is built by MOBILEGL_BUILD_DISAGGREGATED, the one option this skeleton
// adds, and selected at RUNTIME (plan appendix B: MOBILEGL_TRANSPORT =
// monolith / inproc / spawn / ...). The plan also reserves a separate
// MOBILEGL_BUILD_DISAGGREGATED_INPROC option for the role-isolation shim that
// a single-process CI build will need; that option does not exist yet, and
// nothing here depends on it.
//
// Messages are queued whole, so no framing bytes are involved; the size cap is
// still enforced so that a payload which would be illegal on a socket is
// illegal here too and does not pass CI only to fail after the switch to
// `spawn`.
//
// Descriptor passing is a plain dup(): both ends are the same process, so
// there is nothing to transfer, but the API stays identical so callers can be
// written once.
#pragma once
#include "Doorbell.h"
#include "ITransport.h"
#include <memory>
namespace MobileGL::MG_Remote::Transport {
class InProcessChannel;
class InProcessTransport final : public ITransport {
public:
~InProcessTransport() override;
// Creates one connected pair. Endpoint 0 is the client, endpoint 1 the
// server; both share one channel and either may be destroyed first.
static void CreatePair(std::unique_ptr<InProcessTransport>& outClient,
std::unique_ptr<InProcessTransport>& outServer);
MobileGLResult SendFrame(MobileGLByteSpan bytes) override;
MobileGLResult ReceiveFrame(MobileGLMutableByteSpan buffer, std::uint64_t* outSize,
std::uint32_t timeoutMs) override;
std::uint64_t PeekFrameSize() override;
MobileGLResult ShareFd(int fd, MobileGLByteSpan sideband) override;
MobileGLResult ReceiveFd(int* outFd, MobileGLMutableByteSpan sideband,
std::uint64_t* outSidebandSize, std::uint32_t timeoutMs) override;
void Shutdown() override;
TransportRole Role() const override { return TransportRole::InProcess; }
// The wake channel for the SEG_CMD/SEG_STAGE rings living beside this
// transport: ring the peer's bell after publishing a watermark (only
// when its park flag is set - see NotifyIfParked), park on your own.
Doorbell& PeerDoorbell();
Doorbell& SelfDoorbell();
private:
InProcessTransport(std::shared_ptr<InProcessChannel> channel, int endpoint);
std::shared_ptr<InProcessChannel> m_channel;
int m_endpoint = 0;
};
} // namespace MobileGL::MG_Remote::Transport
+306
View File
@@ -0,0 +1,306 @@
// MobileGL - MobileGL/MG_Remote/Transport/Ring.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 "Ring.h"
#include <MG_Util/Debug/Log.h>
#include <cstring>
namespace MobileGL::MG_Remote::Transport {
namespace {
constexpr std::uint64_t Align8(std::uint64_t value) {
return (value + (kRingRecordAlignment - 1)) & ~(kRingRecordAlignment - 1);
}
bool IsPowerOfTwo(std::uint64_t value) { return value != 0 && (value & (value - 1)) == 0; }
std::atomic<std::uint64_t>& Head(RingControl& c, RingCursorSet which) {
return which == RingCursorSet::Cmd ? c.cmdHead : c.stageHead;
}
const std::atomic<std::uint64_t>& Head(const RingControl& c, RingCursorSet which) {
return which == RingCursorSet::Cmd ? c.cmdHead : c.stageHead;
}
std::atomic<std::uint64_t>& AppliedTail(RingControl& c, RingCursorSet which) {
return which == RingCursorSet::Cmd ? c.cmdAppliedTail : c.stageAppliedTail;
}
const std::atomic<std::uint64_t>& AppliedTail(const RingControl& c, RingCursorSet which) {
return which == RingCursorSet::Cmd ? c.cmdAppliedTail : c.stageAppliedTail;
}
std::atomic<std::uint64_t>& RetiredTail(RingControl& c, RingCursorSet which) {
return which == RingCursorSet::Cmd ? c.cmdRetiredTail : c.stageRetiredTail;
}
const std::atomic<std::uint64_t>& RetiredTail(const RingControl& c, RingCursorSet which) {
return which == RingCursorSet::Cmd ? c.cmdRetiredTail : c.stageRetiredTail;
}
} // namespace
void InitRingControl(RingControl& control) {
std::memset(static_cast<void*>(&control), 0, sizeof(RingControl));
// 0 means "uninitialized" for both generations, so a peer that reads a
// zero page can tell it from a legal generation.
control.serverEpoch.store(1, std::memory_order_relaxed);
control.ringGeneration.store(1, std::memory_order_relaxed);
}
bool RingCursorsValid(const RingControl& control, RingCursorSet cursors,
std::uint64_t capacityBytes) {
const std::uint64_t head = Head(control, cursors).load(std::memory_order_acquire);
const std::uint64_t applied = AppliedTail(control, cursors).load(std::memory_order_acquire);
const std::uint64_t retired = RetiredTail(control, cursors).load(std::memory_order_acquire);
if (applied > head || retired > applied) {
return false;
}
return head - retired <= capacityBytes;
}
MobileGLResult HardDrainRing(RingControl& control, RingCursorSet cursors) {
const std::uint64_t head = Head(control, cursors).load(std::memory_order_acquire);
const std::uint64_t applied = AppliedTail(control, cursors).load(std::memory_order_acquire);
const std::uint64_t retired = RetiredTail(control, cursors).load(std::memory_order_acquire);
if (head != applied || applied != retired) {
MGLOG_E("MG_Remote ring: hard drain refused, ring is not quiesced "
"(head=%llu applied=%llu retired=%llu)",
static_cast<unsigned long long>(head),
static_cast<unsigned long long>(applied),
static_cast<unsigned long long>(retired));
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
// Cursors stay monotonic across the drain - only the generation moves,
// so any offset either side cached is now recognisably stale.
control.ringGeneration.fetch_add(1, std::memory_order_acq_rel);
return MOBILEGL_OK;
}
// -----------------------------------------------------------------------
// Producer
// -----------------------------------------------------------------------
RingProducer::RingProducer(RingControl* control, void* base, std::uint64_t capacityBytes,
RingCursorSet cursors)
: m_control(control), m_base(static_cast<std::uint8_t*>(base)), m_capacity(capacityBytes),
m_mask(capacityBytes - 1), m_cursors(cursors) {
if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) ||
capacityBytes < kMinRingCapacity || capacityBytes > kMaxRingCapacity) {
MGLOG_E("MG_Remote ring: producer rejected, capacity %llu must be a power of two "
"between %llu and %llu bytes over a non-null mapping (a record may be at most "
"half the ring, and the record header's size field is 32-bit, so a bigger ring "
"would truncate it)",
static_cast<unsigned long long>(capacityBytes),
static_cast<unsigned long long>(kMinRingCapacity),
static_cast<unsigned long long>(kMaxRingCapacity));
m_control = nullptr;
m_base = nullptr;
m_capacity = 0;
m_mask = 0;
return;
}
m_localHead = Head(*control, cursors).load(std::memory_order_acquire);
}
std::uint64_t RingProducer::TailForReclaim() const {
// The conservative watermark: a slot borrowed into the GPU timeline is
// only free after retiredTail passes it. A consumer that never borrows
// publishes retired together with applied, so this costs nothing there.
return RetiredTail(*m_control, m_cursors).load(std::memory_order_acquire);
}
std::uint64_t RingProducer::FreeBytes() const {
if (m_control == nullptr) {
return 0;
}
const std::uint64_t inFlight = m_localHead - TailForReclaim();
return inFlight >= m_capacity ? 0 : m_capacity - inFlight;
}
void* RingProducer::Reserve(std::uint16_t kind, std::uint16_t flags,
std::uint64_t payloadBytes) {
if (m_control == nullptr) {
return nullptr;
}
const std::uint64_t total = Align8(sizeof(RingRecordHeader) + payloadBytes);
if (total > MaxRecordBytes()) {
// A single record larger than HALF the ring is a caller bug: the
// record catalogue has to chunk oversized payloads (large subdata
// becomes several records) rather than emit one giant record.
//
// Half, not the whole ring, because a record has to be placeable at
// EVERY head offset of an empty ring. Straddling the wrap boundary
// costs a pad of spaceToEnd bytes on top of the record, and with
// spaceToEnd < total that is at most 2*total-8, which stays within
// the capacity exactly up to capacity/2. Above it the record is
// placeable at some offsets and not at others: at head offset 16 of
// an empty 256-byte ring a 248-byte record needs 240+248 bytes while
// FreeBytes() reports 256, so a producer that waits for FreeBytes()
// >= total stalls forever, and nothing is ever logged. Refusing here
// makes that impossible - a nullptr with FreeBytes() >= total can no
// longer mean "wait".
MGLOG_E("MG_Remote ring: record kind %u of %llu bytes exceeds half of a %llu byte ring; "
"the emitter must chunk it",
static_cast<unsigned>(kind), static_cast<unsigned long long>(total),
static_cast<unsigned long long>(m_capacity));
return nullptr;
}
const std::uint64_t offset = m_localHead & m_mask;
const std::uint64_t spaceToEnd = m_capacity - offset;
// Every record is a multiple of 8, so the distance to the wrap boundary
// is too, and a pad header always fits.
const bool needsPad = spaceToEnd < total;
const std::uint64_t needed = needsPad ? spaceToEnd + total : total;
if (FreeBytes() < needed) {
MGLOG_D("MG_Remote ring: full, %llu bytes free, %llu needed",
static_cast<unsigned long long>(FreeBytes()),
static_cast<unsigned long long>(needed));
return nullptr;
}
if (needsPad) {
RingRecordHeader pad{};
pad.kind = kRingPadRecordKind;
pad.flags = kRecPad;
pad.size = static_cast<std::uint32_t>(spaceToEnd);
std::memcpy(SlotAt(m_localHead), &pad, sizeof(pad));
m_localHead += spaceToEnd;
}
RingRecordHeader header{};
header.kind = kind;
header.flags = static_cast<std::uint16_t>(flags & ~static_cast<std::uint16_t>(kRecPad));
header.size = static_cast<std::uint32_t>(total);
std::uint8_t* slot = SlotAt(m_localHead);
std::memcpy(slot, &header, sizeof(header));
m_localHead += total;
return slot + sizeof(RingRecordHeader);
}
void RingProducer::Publish() {
if (m_control == nullptr) {
return;
}
// Release: everything written into the slots happens-before the peer's
// acquire load of the head.
Head(*m_control, m_cursors).store(m_localHead, std::memory_order_release);
}
// -----------------------------------------------------------------------
// Consumer
// -----------------------------------------------------------------------
RingConsumer::RingConsumer(RingControl* control, void* base, std::uint64_t capacityBytes,
RingCursorSet cursors)
: m_control(control), m_base(static_cast<const std::uint8_t*>(base)),
m_capacity(capacityBytes), m_mask(capacityBytes - 1), m_cursors(cursors) {
if (control == nullptr || base == nullptr || !IsPowerOfTwo(capacityBytes) ||
capacityBytes < kMinRingCapacity || capacityBytes > kMaxRingCapacity) {
MGLOG_E("MG_Remote ring: consumer rejected, capacity %llu must be a power of two "
"between %llu and %llu bytes over a non-null mapping (a record may be at most "
"half the ring, and the record header's size field is 32-bit, so a bigger ring "
"would truncate it)",
static_cast<unsigned long long>(capacityBytes),
static_cast<unsigned long long>(kMinRingCapacity),
static_cast<unsigned long long>(kMaxRingCapacity));
m_control = nullptr;
m_base = nullptr;
m_capacity = 0;
m_mask = 0;
return;
}
m_localTail = AppliedTail(*control, cursors).load(std::memory_order_acquire);
}
bool RingConsumer::Pop(RingRecordView& out, bool* outCorrupt) {
if (outCorrupt != nullptr) {
*outCorrupt = false;
}
if (m_control == nullptr) {
return false;
}
const std::uint64_t head = Head(*m_control, m_cursors).load(std::memory_order_acquire);
while (m_localTail != head) {
const std::uint64_t available = head - m_localTail;
if (available < sizeof(RingRecordHeader) || available > m_capacity) {
MGLOG_E("MG_Remote ring: %llu bytes between tail and head is impossible for a %llu "
"byte ring",
static_cast<unsigned long long>(available),
static_cast<unsigned long long>(m_capacity));
if (outCorrupt != nullptr) {
*outCorrupt = true;
}
return false;
}
const std::uint64_t offset = m_localTail & m_mask;
RingRecordHeader header{};
std::memcpy(&header, m_base + offset, sizeof(header));
// SEG_CMD is written by the peer process: compile-time asserts on
// record sizes cannot see runtime corruption, so every dispatch is
// preceded by these bounds checks and a violation is fatal, never a
// retry (plan section 6.3, runtime bounds discipline).
const std::uint64_t size = header.size;
if (size < sizeof(RingRecordHeader) || (size % kRingRecordAlignment) != 0 ||
size > available || offset + size > m_capacity) {
MGLOG_E("MG_Remote ring: corrupt record header at cursor %llu "
"(kind=%u flags=0x%04X size=%u available=%llu)",
static_cast<unsigned long long>(m_localTail),
static_cast<unsigned>(header.kind), static_cast<unsigned>(header.flags),
header.size, static_cast<unsigned long long>(available));
if (outCorrupt != nullptr) {
*outCorrupt = true;
}
return false;
}
if ((header.flags & kRecPad) != 0) {
m_localTail += size;
continue;
}
out.kind = header.kind;
out.flags = header.flags;
out.payload = m_base + offset + sizeof(RingRecordHeader);
// Includes the alignment tail; the record catalogue knows the real
// payload length.
out.payloadSize = size - sizeof(RingRecordHeader);
out.cursor = m_localTail;
m_localTail += size;
return true;
}
return false;
}
void RingConsumer::PublishApplied() {
if (m_control == nullptr) {
return;
}
AppliedTail(*m_control, m_cursors).store(m_localTail, std::memory_order_release);
}
void RingConsumer::PublishRetired() {
if (m_control == nullptr) {
return;
}
// retiredTail must never overtake appliedTail, so publish both.
AppliedTail(*m_control, m_cursors).store(m_localTail, std::memory_order_release);
RetiredTail(*m_control, m_cursors).store(m_localTail, std::memory_order_release);
}
void RingConsumer::PublishRetiredUpTo(std::uint64_t cursor) {
if (m_control == nullptr) {
return;
}
const std::uint64_t applied = AppliedTail(*m_control, m_cursors).load(std::memory_order_acquire);
const std::uint64_t clamped = cursor > applied ? applied : cursor;
const std::uint64_t current = RetiredTail(*m_control, m_cursors).load(std::memory_order_relaxed);
if (clamped > current) {
RetiredTail(*m_control, m_cursors).store(clamped, std::memory_order_release);
}
}
} // namespace MobileGL::MG_Remote::Transport
+256
View File
@@ -0,0 +1,256 @@
// MobileGL - MobileGL/MG_Remote/Transport/Ring.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
// SEG_CMD / SEG_STAGE ring control and the SPSC producer/consumer over it.
//
// RingControl is the shared page at the head of SEG_CMD, laid out exactly as
// the inherited transport design (plan section 8.1, referring the earlier
// plan's section 6.2) specifies:
//
// - TWO independent cursor triples, one for SEG_CMD and one for SEG_STAGE.
// The stage ring needs its own because "SEG_STAGE has less than a quarter
// left" is a publish trigger and that occupancy cannot be derived from the
// command ring's cursors, and because a stage slot retires on a different
// event than a command record does.
// - THREE separate sequence watermarks. Conflating them is the classic bug:
// appliedSeq releases *AppliedTail, submittedSeq releases staging,
// retiredSeq / completedFrameSerial release *RetiredTail and adopted
// stores.
// - TWO tails per ring, not one. Once the server borrows a ring slot into
// the GPU timeline instead of copying it out again, that slot can only be
// recycled after completedFrameSerial; a single tail would silently
// degrade to conservative reclaim the day borrowing lands.
// - Both park flags, because the doorbell is bidirectional: without the
// server->client direction every client wait degenerates into a
// cross-process spin on one shared cache line (a whole 16.6ms frame of a
// big core, on a phone, competing with the GPU and the game's JVM).
//
// Cursors are monotonically increasing byte counts; the ring is indexed with a
// power-of-two mask. They are never reset, so a torn read can never look like
// a valid earlier position. ringGeneration is bumped after a hard drain to
// invalidate every cached offset.
//
// Record framing inside the ring is the 8-byte header below, which is the
// layout the plan's RecHeader already fixes ({u16 kind, u16 flags, u32 size},
// size including the header and a multiple of 8). The record CATALOGUE
// (Records.def / PipeCalls.def) is a separate deliverable; the ring itself
// only needs kind/flags/size, so it can carry the real records the day they
// land without changing shape.
#pragma once
#include "../Protocol/mg_protocol_base.h"
#include <atomic>
#include <cstddef>
#include <cstdint>
namespace MobileGL::MG_Remote::Transport {
// The shared control page. One 4 KiB page so it can be mapped alone, with
// each contended group on its own cache line.
struct alignas(4096) RingControl {
// ---- SEG_CMD cursors ------------------------------------------------
alignas(64) std::atomic<std::uint64_t> cmdHead; // producer: bytes written
alignas(64) std::atomic<std::uint64_t> cmdAppliedTail; // consumer: bytes decoded/copied out
std::atomic<std::uint64_t> cmdRetiredTail; // consumer: borrowed slots released
// ---- SEG_STAGE cursors ----------------------------------------------
alignas(64) std::atomic<std::uint64_t> stageHead;
alignas(64) std::atomic<std::uint64_t> stageAppliedTail;
std::atomic<std::uint64_t> stageRetiredTail;
// ---- sequence / frame watermarks -------------------------------------
alignas(64) std::atomic<std::uint64_t> appliedSeq; // records applied
std::atomic<std::uint64_t> submittedSeq; // handed to the driver
std::atomic<std::uint64_t> retiredSeq; // GPU finished
std::atomic<std::uint64_t> completedFrameSerial;
std::atomic<std::uint64_t> presentAckSerial;
// ---- doorbell / generation -------------------------------------------
alignas(64) std::atomic<std::uint32_t> serverEpoch; // ++ on context loss / server restart
std::atomic<std::uint32_t> ringGeneration; // ++ after a hard drain
std::atomic<std::uint32_t> consumerParked; // server asleep, producer must ring
std::atomic<std::uint32_t> producerParked; // client asleep, server must ring
std::atomic<std::uint32_t> eventRingFull; // SEG_EVENT full, server stopped applying
std::atomic<std::uint32_t> eventDropped; // dropped lossy events
};
static_assert(sizeof(RingControl) == 4096, "RingControl must be exactly one page");
static_assert(alignof(RingControl) == 4096, "RingControl must be page aligned");
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
"the ring cursors are shared across processes: they must be lock-free");
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
"the doorbell flags are shared across processes: they must be lock-free");
// Per-record header. Prefix-identical to the plan's RecHeader so the
// generated record catalogue drops straight in.
struct RingRecordHeader {
std::uint16_t kind;
std::uint16_t flags;
std::uint32_t size; // header + payload + alignment padding, multiple of 8
};
static_assert(sizeof(RingRecordHeader) == 8, "RecHeader is 8 bytes on the wire");
enum RingRecordFlags : std::uint16_t {
kRecNone = 0,
kRecNeedsAck = 1u << 0,
kRecHasBlob = 1u << 1,
kRecPad = 1u << 2, // filler to the wrap boundary, no payload meaning
kRecBorrowSlot = 1u << 3, // slot is borrowed into the GPU timeline; retires late
kRecVarTail = 1u << 4,
};
// Reserved kind for the wrap filler. The catalogue starts at 1.
inline constexpr std::uint16_t kRingPadRecordKind = 0;
inline constexpr std::uint64_t kRingRecordAlignment = 8;
// Largest ring the 8-byte header can describe. Both a record's size and a
// wrap filler's size are bounded only by the capacity and are stored in
// RingRecordHeader::size, which is 32 bits by wire contract: a ring of
// 4 GiB or more would silently truncate them, and the consumer would then
// bounds-check the truncated value against the real one. SEG_CMD is 8 MiB
// and SEG_STAGE 32 MiB today, so this is unreachable - it is the same
// class of construction-time guard as the power-of-two check beside it.
inline constexpr std::uint64_t kMaxRingCapacity = 0xFFFFFFFFull;
// Smallest ring: two record headers. A record may be at most HALF the ring
// (see RingProducer::Reserve), so a ring of one header could carry nothing
// at all - not even the smallest record, a bare header.
inline constexpr std::uint64_t kMinRingCapacity = 2 * sizeof(RingRecordHeader);
// Which cursor triple a producer/consumer pair drives.
enum class RingCursorSet : std::uint32_t {
Cmd = 0,
Stage = 1,
};
// Zeroes every cursor and starts serverEpoch / ringGeneration at 1, so that
// a zero read is always "uninitialized", never a legal generation.
void InitRingControl(RingControl& control);
// head >= appliedTail >= retiredTail, and the ring never holds more than
// its capacity. False means the shared page is corrupt (or a peer is
// misbehaving), which is a Fatal{ProtocolCorruption}, never a retry.
bool RingCursorsValid(const RingControl& control, RingCursorSet cursors,
std::uint64_t capacityBytes);
// Bumps ringGeneration, invalidating every offset either side has cached.
// Both sides must be quiesced and the ring fully drained
// (head == appliedTail == retiredTail); otherwise this returns
// MOBILEGL_ERR_INVALID_ARGUMENT and changes nothing.
MobileGLResult HardDrainRing(RingControl& control, RingCursorSet cursors);
// A record as seen by the consumer.
struct RingRecordView {
std::uint16_t kind = 0;
std::uint16_t flags = 0;
const void* payload = nullptr;
std::uint64_t payloadSize = 0;
std::uint64_t cursor = 0; // producer cursor at the START of this record
};
// Single producer. Not thread-safe: one writer thread, by construction.
class RingProducer {
public:
RingProducer() = default;
// `base` is the ring's byte area (NOT the control page) and
// `capacityBytes` must be a power of two between kMinRingCapacity and
// kMaxRingCapacity. Anything else leaves Valid() false.
RingProducer(RingControl* control, void* base, std::uint64_t capacityBytes,
RingCursorSet cursors);
bool Valid() const { return m_control != nullptr; }
// Bytes still writable before the consumer has to catch up.
std::uint64_t FreeBytes() const;
// Reserves room for one record and returns a pointer to its payload,
// or nullptr when the ring is full. The payload is uninitialized;
// alignment padding at its tail is NOT zeroed. Emits a pad record
// automatically when the record would straddle the wrap boundary, so
// every record is contiguous.
//
// A record whose total (header + payload, rounded up to 8) exceeds
// MaxRecordBytes() == Capacity()/2 is refused outright, with an error
// log and however empty the ring is: chunking it is the emitter's job
// (plan section 8.2, the G3 chunking rule). Half is exact, not
// conservative - it is the largest record EVERY head offset can place,
// because a wrap pad costs at most total-8 bytes on top of the record
// and 2*total-8 <= capacity-8 holds exactly up to capacity/2. Above it
// a record is placeable at some offsets and not at others, and a
// producer waiting for FreeBytes() >= total stalls forever on an empty
// ring. So: nullptr with FreeBytes() >= total never means "wait"; it
// can only mean "too big, chunk".
void* Reserve(std::uint16_t kind, std::uint16_t flags, std::uint64_t payloadBytes);
// The largest header+payload total Reserve accepts: Capacity()/2. This
// is the number the emitter chunks against.
std::uint64_t MaxRecordBytes() const { return m_capacity / 2; }
// Makes every reserved record visible to the consumer (release store on
// the head cursor). Cheap: publishing per record is fine, batching 8-16
// only amortizes the doorbell store.
void Publish();
// Producer-local cursor including records not yet published.
std::uint64_t LocalHead() const { return m_localHead; }
std::uint64_t Capacity() const { return m_capacity; }
private:
std::uint64_t TailForReclaim() const;
std::uint8_t* SlotAt(std::uint64_t cursor) const {
return m_base + static_cast<std::size_t>(cursor & m_mask);
}
RingControl* m_control = nullptr;
std::uint8_t* m_base = nullptr;
std::uint64_t m_capacity = 0;
std::uint64_t m_mask = 0;
std::uint64_t m_localHead = 0;
RingCursorSet m_cursors = RingCursorSet::Cmd;
};
// Single consumer. Not thread-safe: one reader thread, by construction.
class RingConsumer {
public:
RingConsumer() = default;
RingConsumer(RingControl* control, void* base, std::uint64_t capacityBytes,
RingCursorSet cursors);
bool Valid() const { return m_control != nullptr; }
// Pops the next record, skipping wrap fillers. Returns false when the
// ring is empty at this moment. A record whose header is impossible
// (size not 8-aligned, smaller than a header, or larger than what the
// producer has published) is refused: *outCorrupt is set, which the
// caller must escalate to Fatal{ProtocolCorruption} rather than retry.
bool Pop(RingRecordView& out, bool* outCorrupt = nullptr);
// Publishes the applied cursor, releasing those bytes to the producer.
void PublishApplied();
// Publishes the retired cursor. Records without kRecBorrowSlot retire
// as soon as they are applied; borrowed slots retire on
// completedFrameSerial, which is why this is a separate call.
void PublishRetired();
void PublishRetiredUpTo(std::uint64_t cursor);
std::uint64_t LocalTail() const { return m_localTail; }
std::uint64_t Capacity() const { return m_capacity; }
private:
RingControl* m_control = nullptr;
const std::uint8_t* m_base = nullptr;
std::uint64_t m_capacity = 0;
std::uint64_t m_mask = 0;
std::uint64_t m_localTail = 0;
RingCursorSet m_cursors = RingCursorSet::Cmd;
};
} // namespace MobileGL::MG_Remote::Transport
@@ -0,0 +1,49 @@
// MobileGL - MobileGL/MG_Remote/Transport/ShmSegment.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
// Platform-independent half of ShmSegment. The create/map/close bodies live in
// ShmSegmentPosix.cpp and ShmSegmentWin32.cpp.
#include "ShmSegment.h"
#include <cstring>
#include <utility>
namespace MobileGL::MG_Remote::Transport {
ShmSegment::~ShmSegment() { Close(); }
ShmSegment::ShmSegment(ShmSegment&& other) noexcept { Steal(std::move(other)); }
ShmSegment& ShmSegment::operator=(ShmSegment&& other) noexcept {
if (this != &other) {
Close();
Steal(std::move(other));
}
return *this;
}
void ShmSegment::Steal(ShmSegment&& other) noexcept {
std::memcpy(m_name, other.m_name, sizeof(m_name));
m_mapping = other.m_mapping;
m_nativeHandle = other.m_nativeHandle;
m_size = other.m_size;
m_fd = other.m_fd;
m_readOnly = other.m_readOnly;
std::memset(other.m_name, 0, sizeof(other.m_name));
other.m_mapping = nullptr;
other.m_nativeHandle = nullptr;
other.m_size = 0;
other.m_fd = -1;
other.m_readOnly = false;
}
bool ShmSegment::Valid() const { return m_size != 0 && (m_fd >= 0 || m_nativeHandle != nullptr); }
} // namespace MobileGL::MG_Remote::Transport
+87
View File
@@ -0,0 +1,87 @@
// MobileGL - MobileGL/MG_Remote/Transport/ShmSegment.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
// One shared-memory segment: SEG_CMD, SEG_STAGE, SEG_REPLY, SEG_EVENT, a
// per-object SEG_SHADOW or a SEG_ADOPT store (inherited segment layout, plan
// section 8.1).
//
// Creation matrix (earlier plan section 6.1):
// - Android: ASharedMemory_create (API 26; libc's memfd_create wrapper
// only appears at API 30, which is above our floor)
// - desktop Linux: syscall(SYS_memfd_create, ...) directly, for the same
// reason - the glibc wrapper is recent and this file has to
// build against old sysroots
// - other POSIX: shm_open + immediate shm_unlink, the fd keeps it alive
// - Windows: CreateFileMappingW in the Local\ namespace
//
// Transfer is NOT done here. On POSIX the fd travels by SCM_RIGHTS
// (FdPassing.h / ITransport::ShareFd) and the name is only a debugging label;
// on Windows the section name travels inside the SegmentRef table.
//
// The Windows implementation is compile-guarded and untested at the time it
// was written: no Windows machine is a correctness gate for this project.
#pragma once
#include "../Protocol/mg_protocol_base.h"
#include <cstddef>
#include <cstdint>
namespace MobileGL::MG_Remote::Transport {
inline constexpr std::size_t kShmNameMax = 128;
class ShmSegment {
public:
ShmSegment() = default;
~ShmSegment();
ShmSegment(const ShmSegment&) = delete;
ShmSegment& operator=(const ShmSegment&) = delete;
ShmSegment(ShmSegment&& other) noexcept;
ShmSegment& operator=(ShmSegment&& other) noexcept;
// Creates a segment of `size` bytes owned by this process. `nameHint`
// is a short debug label (Windows: part of the section name peers
// resolve). The segment is NOT mapped yet.
static MobileGLResult Create(const char* nameHint, std::uint64_t size, ShmSegment& out);
// POSIX only: adopts a descriptor received over SCM_RIGHTS. Takes
// ownership of `fd` on success; on failure the caller still owns it.
static MobileGLResult Adopt(int fd, std::uint64_t size, ShmSegment& out);
// Windows only: opens a section the peer published by name.
static MobileGLResult OpenNamed(const char* name, std::uint64_t size, ShmSegment& out);
// Maps the whole segment. Read-only mappings are what the peer gets for
// a segment it does not own (SEG_CMD/SEG_STAGE on the server side).
MobileGLResult Map(bool readOnly);
void Unmap();
void Close(); // unmaps and releases the descriptor/handle
bool Valid() const;
void* Data() const { return m_mapping; }
std::uint64_t Size() const { return m_size; }
bool MappedReadOnly() const { return m_readOnly; }
const char* Name() const { return m_name; }
// POSIX: the descriptor to hand to ShareFd. -1 on Windows.
int Fd() const { return m_fd; }
private:
void Steal(ShmSegment&& other) noexcept;
char m_name[kShmNameMax] = {};
void* m_mapping = nullptr;
void* m_nativeHandle = nullptr; // Windows HANDLE; unused on POSIX
std::uint64_t m_size = 0;
int m_fd = -1;
bool m_readOnly = false;
};
} // namespace MobileGL::MG_Remote::Transport
@@ -0,0 +1,191 @@
// MobileGL - MobileGL/MG_Remote/Transport/ShmSegmentPosix.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 "ShmSegment.h"
#if !defined(_WIN32)
#include <MG_Util/Debug/Log.h>
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#if defined(__ANDROID__)
#include <android/sharedmem.h>
#elif defined(__linux__)
#include <sys/syscall.h>
#ifndef MFD_CLOEXEC
#define MFD_CLOEXEC 0x0001U
#endif
#endif
namespace MobileGL::MG_Remote::Transport {
namespace {
void CopyName(char (&dst)[kShmNameMax], const char* src) {
if (src == nullptr) {
dst[0] = '\0';
return;
}
std::snprintf(dst, kShmNameMax, "%s", src);
}
#if !defined(__ANDROID__)
// Unique per process; only used by the shm_open fallback, whose name
// must not collide with a concurrent creator's.
std::atomic<std::uint32_t> g_shmCounter{0};
#endif
} // namespace
MobileGLResult ShmSegment::Create(const char* nameHint, std::uint64_t size, ShmSegment& out) {
if (size == 0) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
out.Close();
char label[kShmNameMax];
std::snprintf(label, sizeof(label), "mgl-%s", nameHint != nullptr ? nameHint : "seg");
int fd = -1;
#if defined(__ANDROID__)
// API 26. libc's memfd_create wrapper is API 30, above MobileGL's floor.
fd = ASharedMemory_create(label, static_cast<size_t>(size));
if (fd < 0) {
MGLOG_W("MG_Remote shm: ASharedMemory_create(%s, %llu) failed (errno=%d)", label,
static_cast<unsigned long long>(size), errno);
}
#elif defined(__linux__)
// Raw syscall, not the glibc wrapper: the wrapper is too recent to rely
// on across the sysroots this builds against.
fd = static_cast<int>(::syscall(SYS_memfd_create, label, MFD_CLOEXEC));
if (fd >= 0 && ::ftruncate(fd, static_cast<off_t>(size)) != 0) {
MGLOG_E("MG_Remote shm: ftruncate(%llu) failed (errno=%d)",
static_cast<unsigned long long>(size), errno);
::close(fd);
fd = -1;
}
#endif
#if !defined(__ANDROID__)
if (fd < 0) {
// Fallback: shm_open + immediate unlink. The name disappears at
// once; the descriptor is what keeps the object alive and what
// travels by SCM_RIGHTS.
char shmName[kShmNameMax];
std::snprintf(shmName, sizeof(shmName), "/mgl-%d-%u-%s", static_cast<int>(::getpid()),
g_shmCounter.fetch_add(1, std::memory_order_relaxed),
nameHint != nullptr ? nameHint : "seg");
fd = ::shm_open(shmName, O_RDWR | O_CREAT | O_EXCL, 0600);
if (fd < 0) {
MGLOG_E("MG_Remote shm: shm_open(%s) failed (errno=%d)", shmName, errno);
return MOBILEGL_ERR_SHM_EXHAUSTED;
}
::shm_unlink(shmName);
if (::ftruncate(fd, static_cast<off_t>(size)) != 0) {
MGLOG_E("MG_Remote shm: ftruncate(%llu) failed (errno=%d)",
static_cast<unsigned long long>(size), errno);
::close(fd);
return MOBILEGL_ERR_SHM_EXHAUSTED;
}
CopyName(out.m_name, shmName);
} else {
CopyName(out.m_name, label);
}
#else
if (fd < 0) {
return MOBILEGL_ERR_SHM_EXHAUSTED;
}
CopyName(out.m_name, label);
#endif
out.m_fd = fd;
out.m_size = size;
out.m_nativeHandle = nullptr;
out.m_mapping = nullptr;
out.m_readOnly = false;
return MOBILEGL_OK;
}
MobileGLResult ShmSegment::Adopt(int fd, std::uint64_t size, ShmSegment& out) {
if (fd < 0 || size == 0) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
// The peer's declared size is not trusted: a segment smaller than what
// the announcement claims would turn every later offset into an
// out-of-bounds map.
struct stat st{};
if (::fstat(fd, &st) == 0 && st.st_size > 0 &&
static_cast<std::uint64_t>(st.st_size) < size) {
MGLOG_E("MG_Remote shm: peer announced %llu bytes but the descriptor is %lld",
static_cast<unsigned long long>(size), static_cast<long long>(st.st_size));
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
out.Close();
out.m_fd = fd; // ownership transferred
out.m_size = size;
out.m_nativeHandle = nullptr;
out.m_mapping = nullptr;
out.m_readOnly = false;
CopyName(out.m_name, "adopted");
return MOBILEGL_OK;
}
MobileGLResult ShmSegment::OpenNamed(const char*, std::uint64_t, ShmSegment&) {
// POSIX shares descriptors, not names.
return MOBILEGL_ERR_UNSUPPORTED;
}
MobileGLResult ShmSegment::Map(bool readOnly) {
if (m_fd < 0 || m_size == 0) {
return MOBILEGL_ERR_NOT_INITIALIZED;
}
if (m_mapping != nullptr) {
if (m_readOnly == readOnly) {
return MOBILEGL_OK;
}
Unmap();
}
const int prot = readOnly ? PROT_READ : (PROT_READ | PROT_WRITE);
void* addr = ::mmap(nullptr, static_cast<size_t>(m_size), prot, MAP_SHARED, m_fd, 0);
if (addr == MAP_FAILED) {
MGLOG_E("MG_Remote shm: mmap of %llu bytes failed (errno=%d)",
static_cast<unsigned long long>(m_size), errno);
return MOBILEGL_ERR_OUT_OF_MEMORY;
}
m_mapping = addr;
m_readOnly = readOnly;
return MOBILEGL_OK;
}
void ShmSegment::Unmap() {
if (m_mapping != nullptr) {
::munmap(m_mapping, static_cast<size_t>(m_size));
m_mapping = nullptr;
}
}
void ShmSegment::Close() {
Unmap();
if (m_fd >= 0) {
::close(m_fd);
m_fd = -1;
}
m_size = 0;
m_readOnly = false;
m_name[0] = '\0';
}
} // namespace MobileGL::MG_Remote::Transport
#endif // !_WIN32
@@ -0,0 +1,162 @@
// MobileGL - MobileGL/MG_Remote/Transport/ShmSegmentWin32.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
// Windows half of ShmSegment: a named file-mapping section in the Local\
// namespace, which the peer opens by the name carried in SegmentRef.
//
// UNTESTED. This project's Windows machine is not a correctness gate (its
// Vulkan lacks vkCreateHeadlessSurfaceEXT and accounts for most of its
// baseline integration failures), and the whole disaggregated build is gated
// behind MOBILEGL_BUILD_DISAGGREGATED, which is OFF by default. It is written
// now so the abstraction is shaped by two real platforms rather than one.
#include "ShmSegment.h"
#if defined(_WIN32)
#include <MG_Util/Debug/Log.h>
#include <atomic>
#include <cstdio>
#include <cstring>
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
namespace MobileGL::MG_Remote::Transport {
namespace {
std::atomic<std::uint32_t> g_sectionCounter{0};
bool ToWide(const char* utf8, wchar_t* out, int outChars) {
if (utf8 == nullptr || out == nullptr || outChars <= 0) {
return false;
}
const int written = ::MultiByteToWideChar(CP_UTF8, 0, utf8, -1, out, outChars);
return written > 0;
}
} // namespace
MobileGLResult ShmSegment::Create(const char* nameHint, std::uint64_t size, ShmSegment& out) {
if (size == 0) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
out.Close();
char name[kShmNameMax];
std::snprintf(name, sizeof(name), "Local\\mgl-%lu-%u-%s",
static_cast<unsigned long>(::GetCurrentProcessId()),
g_sectionCounter.fetch_add(1, std::memory_order_relaxed),
nameHint != nullptr ? nameHint : "seg");
wchar_t wide[kShmNameMax];
if (!ToWide(name, wide, static_cast<int>(kShmNameMax))) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
HANDLE section = ::CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE,
static_cast<DWORD>(size >> 32),
static_cast<DWORD>(size & 0xFFFFFFFFull), wide);
if (section == nullptr) {
MGLOG_E("MG_Remote shm: CreateFileMappingW(%s, %llu) failed (GetLastError=%lu)", name,
static_cast<unsigned long long>(size),
static_cast<unsigned long>(::GetLastError()));
return MOBILEGL_ERR_SHM_EXHAUSTED;
}
if (::GetLastError() == ERROR_ALREADY_EXISTS) {
::CloseHandle(section);
MGLOG_E("MG_Remote shm: section name %s already exists", name);
return MOBILEGL_ERR_SHM_EXHAUSTED;
}
std::snprintf(out.m_name, kShmNameMax, "%s", name);
out.m_nativeHandle = section;
out.m_size = size;
out.m_fd = -1;
out.m_mapping = nullptr;
out.m_readOnly = false;
return MOBILEGL_OK;
}
MobileGLResult ShmSegment::Adopt(int, std::uint64_t, ShmSegment&) {
// No SCM_RIGHTS here: Windows peers resolve the section by name.
return MOBILEGL_ERR_UNSUPPORTED;
}
MobileGLResult ShmSegment::OpenNamed(const char* name, std::uint64_t size, ShmSegment& out) {
if (name == nullptr || name[0] == '\0' || size == 0) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
out.Close();
wchar_t wide[kShmNameMax];
if (!ToWide(name, wide, static_cast<int>(kShmNameMax))) {
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
HANDLE section = ::OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, wide);
if (section == nullptr) {
MGLOG_E("MG_Remote shm: OpenFileMappingW(%s) failed (GetLastError=%lu)", name,
static_cast<unsigned long>(::GetLastError()));
return MOBILEGL_ERR_INVALID_ARGUMENT;
}
std::snprintf(out.m_name, kShmNameMax, "%s", name);
out.m_nativeHandle = section;
out.m_size = size;
out.m_fd = -1;
out.m_mapping = nullptr;
out.m_readOnly = false;
return MOBILEGL_OK;
}
MobileGLResult ShmSegment::Map(bool readOnly) {
if (m_nativeHandle == nullptr || m_size == 0) {
return MOBILEGL_ERR_NOT_INITIALIZED;
}
if (m_mapping != nullptr) {
if (m_readOnly == readOnly) {
return MOBILEGL_OK;
}
Unmap();
}
void* view = ::MapViewOfFile(static_cast<HANDLE>(m_nativeHandle),
readOnly ? FILE_MAP_READ : FILE_MAP_ALL_ACCESS, 0, 0,
static_cast<SIZE_T>(m_size));
if (view == nullptr) {
MGLOG_E("MG_Remote shm: MapViewOfFile of %llu bytes failed (GetLastError=%lu)",
static_cast<unsigned long long>(m_size),
static_cast<unsigned long>(::GetLastError()));
return MOBILEGL_ERR_OUT_OF_MEMORY;
}
m_mapping = view;
m_readOnly = readOnly;
return MOBILEGL_OK;
}
void ShmSegment::Unmap() {
if (m_mapping != nullptr) {
::UnmapViewOfFile(m_mapping);
m_mapping = nullptr;
}
}
void ShmSegment::Close() {
Unmap();
if (m_nativeHandle != nullptr) {
::CloseHandle(static_cast<HANDLE>(m_nativeHandle));
m_nativeHandle = nullptr;
}
m_size = 0;
m_readOnly = false;
m_name[0] = '\0';
}
} // namespace MobileGL::MG_Remote::Transport
#endif // _WIN32
+33
View File
@@ -0,0 +1,33 @@
// MobileGL - MobileGL/MG_Remote/Transport/WireLog.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 "WireLog.h"
#include <MG_Util/Debug/Log.h>
#include <cstdarg>
#include <cstdio>
namespace MobileGL::MG_Remote::Transport {
void WireLogError(const char* format, ...) {
// One stack line, no allocation: this runs on paths that have just
// decided the connection is unusable.
char line[512];
va_list args;
va_start(args, format);
const int written = std::vsnprintf(line, sizeof(line), format, args);
va_end(args);
if (written < 0) {
MGLOG_E("MG_Remote wire: unformattable diagnostic (format=%s)", format);
return;
}
MGLOG_E("%s", line);
}
} // namespace MobileGL::MG_Remote::Transport
+38
View File
@@ -0,0 +1,38 @@
// MobileGL - MobileGL/MG_Remote/Transport/WireLog.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
// A one-function logging shim for the wire layer's header-only code.
//
// MG_Util/Debug/Log.h includes <Includes.h>, the GL frontend's umbrella
// header - 661 headers, measured with `clang++ -H`. That is fine inside a
// .cpp, and Ring.cpp / Doorbell.cpp / the transports all do it. It is not fine
// in a header of this layer: ITransport.h states the rule ("nothing about a
// byte pipe needs the GL frontend's umbrella header") because these headers
// are included by both roles and by the eventual server-side binary, and
// because the disaggregated build's include-graph purity gate (plan section
// 10.3, gate A) asserts on `-H` output rather than on symbols. Framing.h was
// the one header under Transport/ that broke the rule; it now calls this
// instead, and the umbrella stays inside WireLog.cpp.
//
// ERROR only, deliberately. Everything routed here is a latched protocol
// violation, never per-frame noise; non-critical wire lines use MGLOG_D from a
// .cpp, where the INFO build compiles them out entirely.
#pragma once
namespace MobileGL::MG_Remote::Transport {
// Formats one line and emits it at ERROR level (MGLOG_E). printf-style,
// with the format checked against the arguments at compile time.
#if defined(__GNUC__) || defined(__clang__)
__attribute__((format(printf, 1, 2)))
#endif
void
WireLogError(const char* format, ...);
} // namespace MobileGL::MG_Remote::Transport
@@ -67,6 +67,14 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::NotifyContentWrite(SizeT offset, SizeT size) {
if (size == 0) {
// An empty write moves the serial and nothing else, exactly as NotifySubData
// and NotifyFlushMappedRange do: it wrote no byte, so it must not promote an
// undefined store to "has content" - that would cost the next orphaning
// respecification a full-size upload of bytes the application never wrote.
++m_changeSerial;
return;
}
m_hasDefinedContent = true;
if (m_resource.IsGpuResident()) {
// The write already landed in coherent GPU memory; the backend has no separate
@@ -111,7 +119,10 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::Respecify(SizeT size, const void* data) {
ReleaseMemory();
// The store a live mapping wrote into is about to be replaced, so landing those
// bytes into it would copy a whole mapped range (an adopted arena's map is the
// arena) into storage the next line hands back.
ReleaseMemory(false);
RedefineStorage(size);
if (data && size > 0) {
Memcpy(m_resource.Bytes(), data, size);
@@ -136,7 +147,9 @@ namespace MobileGL::MG_State::GLState {
}
void BufferObject::AllocateImmutableStorage(SizeT size, const void* data, GLbitfield storageFlags) {
ReleaseMemory();
// Same as Respecify: the bytes a live mapping staged have nowhere to land, the
// store they belong to is being replaced.
ReleaseMemory(false);
RedefineStorage(size);
if (data) {
Memcpy(m_resource.Bytes(), data, size);
@@ -190,24 +203,45 @@ namespace MobileGL::MG_State::GLState {
m_usage = usage;
}
void BufferObject::ReleaseMemory() {
void BufferObject::ReleaseMemory(Bool landStagedWrites) {
if (!m_isMapped) return;
if (m_mappingAccess & BufferMappingAccessBit::Write) { // if we wrote to the buffer
// A persistent GPU-resident map wrote straight into coherent GPU memory, so
// there is nothing to copy back and no range to push down on unmap.
if (!m_resource.IsGpuResident() &&
!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
if (landStagedWrites &&
(m_mappingAccess & BufferMappingAccessBit::Write)) { // if we wrote to the buffer
if (!(m_mappingAccess & BufferMappingAccessBit::FlushExplicit)) { // if we didn't flush explicitly
const SizeT mappedLength = m_mappedRange.end - m_mappedRange.start;
if (m_resource.IsGpuResident()) {
// A persistent map of an adopted store wrote straight into coherent
// GPU memory: nothing to copy back, no range to push down. A
// NON-persistent write map is a different thing: the application
// wrote a staging copy (glMapBuffer and glMapBufferRange hand one out
// regardless of where the store lives), and GL requires those bytes
// to be visible to every later command the moment glUnmapBuffer
// returns. Residency used to come only from a coherent persistent
// map, which never has a staging copy, so the copy-back was simply
// skipped for a resident store; residency now also comes from a
// shader storage binding (EnsureGpuResidentStorage at draw time) and
// from large-store adoption (TryAdoptLargeStorage), both of which an
// application then re-initialises through an ordinary map/write/unmap.
// Skipping the copy-back dropped every one of those writes. Land the
// staged bytes through the same route glBufferSubData takes into an
// adopted store - the backend's flush op is for stores it keeps a
// separate copy of and must not run here.
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
LandBytesIntoResidentStore(m_mappedRange.start,
{m_stagingData.data() + m_stagingBias, mappedLength});
}
} else {
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + m_mappedRange.start, m_stagingData.data() + m_stagingBias,
m_mappedRange.end - m_mappedRange.start);
mappedLength);
}
NotifyFlushMappedRange(m_mappedRange, m_mappingAccess);
}
m_stagingData.clear();
}
}
m_stagingData.clear();
m_isMapped = false;
m_mappingAccess = BufferMappingAccessBit::Null;
m_mappedRange = {0, 0};
@@ -227,8 +261,21 @@ namespace MobileGL::MG_State::GLState {
MOBILEGL_ASSERT(end <= m_mappedRange.end, "Flush range out of bounds: mappedRange.end (%zu) < end (%zu)",
m_mappedRange.end, end);
// FLUSH_EXPLICIT maps are never GPU-resident (only coherent maps are adopted), so
// the staged bytes must be copied into the shadow before the backend reads them.
// A FLUSH_EXPLICIT map can sit on an adopted store: the map itself never adopts
// (only a coherent persistent one does), but a shader storage binding or
// large-store adoption may have made the buffer resident before the map. The
// flushed bytes then take the same landing as any other CPU write into an
// adopted store - a persistent map already wrote them in place and only has
// to publish the change, a non-persistent map staged them and has to land
// them. The backend's flush op is for stores it keeps a separate copy of.
if (m_resource.IsGpuResident()) {
if (m_mappingAccess & BufferMappingAccessBit::Persistent) {
NotifyContentWrite(start, length);
} else {
LandBytesIntoResidentStore(start, {m_stagingData.data() + m_stagingBias + offset, length});
}
return;
}
if (!(m_mappingAccess & BufferMappingAccessBit::Persistent)) {
Memcpy(m_resource.Bytes() + start, m_stagingData.data() + m_stagingBias + offset, length);
}
@@ -284,35 +331,48 @@ namespace MobileGL::MG_State::GLState {
data.size, m_size);
// An adopted store's Bytes() IS the memory in-flight frames are reading, and
// GL orders a glBufferSubData after those already-submitted reads. A backend
// that can land the bytes on the GPU timeline takes them here, untouched by
// the mapping - the in-place host write below tore the frames still reading
// the old bytes. The bytes are not current in the mapping until the backend's
// ordered copy executes, so reads reconcile through the same gate GPU-written
// buffers use.
if (m_resource.IsGpuResident() && data.size > 0 && g_bufferBackendOps &&
g_bufferBackendOps->ResidentSubData) {
g_bufferBackendOps->ResidentSubData(*this, atOffset, data);
// GL orders a glBufferSubData after those already-submitted reads: the write
// has to take the resident landing, never a plain host write into the mapping.
// Shadow-backed stores need none of this: the Memcpy below touches only the
// shadow, and the backend's SubData op does its own ordering against in-flight
// work.
if (m_resource.IsGpuResident()) {
LandBytesIntoResidentStore(atOffset, data);
return;
}
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
NotifyContentWrite(atOffset, data.size);
}
// A backend that can land the bytes on the GPU timeline takes them here, untouched
// by the mapping - an in-place host write into coherent memory tore the frames
// still reading the old bytes (Minecraft patches LIVE chunk sections this way).
// The bytes are then not current in the mapping until the backend's ordered copy
// executes, so reads reconcile through the same gate GPU-written buffers use.
//
// Without that op the write lands in place, after retiring the GPU writes this store
// is known to be waiting on: a backend that defers work (DirectVulkan's frame command
// buffer) may still be holding a recorded-but-unsubmitted dispatch that GL orders this
// write AFTER, and writing the mapping now would land the bytes underneath that
// dispatch - its increments then execute on top of the newer data and invert the call
// order. That gate only knows about work that WROTE the store (MarkGpuWritten); work
// that merely READS it - a draw sourcing an adopted vertex arena - is not tracked here,
// so a backend without the op still owes the ordering against its own recorded reads.
// NotifyContentWrite on a resident store only bumps the serial: the backend has no
// separate copy to sync, so no transfer op runs.
void BufferObject::LandBytesIntoResidentStore(SizeT offset, DataPtr bytes) {
if (bytes.size > 0 && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
g_bufferBackendOps->ResidentSubData(*this, offset, bytes);
m_hasDefinedContent = true;
++m_changeSerial;
m_gpuWritePending = true;
return;
}
// An adopted store's Bytes() IS the memory the GPU reads, and a backend that
// defers work (DirectVulkan's frame command buffer) may still be holding a
// recorded-but-unsubmitted dispatch that GL orders this write AFTER. Writing
// the mapping now would land the bytes underneath that dispatch - its
// increments then execute on top of the newer data and invert the call order.
// Retire the pending GPU writes first, as FillSubData already does. Shadow-
// backed stores need none of this: the Memcpy below touches only the shadow,
// and the backend's SubData op does its own ordering against in-flight work.
if (m_resource.IsGpuResident()) {
SyncGpuWrites();
}
Memcpy(m_resource.Bytes() + atOffset, data.data, data.size);
NotifyContentWrite(atOffset, data.size);
Memcpy(m_resource.Bytes() + offset, bytes.data, bytes.size);
NotifyContentWrite(offset, bytes.size);
}
void BufferObject::FillSubData(DataPtr pattern, SizeT atOffset, SizeT size) {
@@ -327,8 +387,13 @@ namespace MobileGL::MG_State::GLState {
"Cannot fill data while buffer is non-persistently mapped.");
if (size == 0) return;
// An adopted store takes the same GPU-timeline landing as UploadSubData: the
// in-place write below would tear in-flight readers of the mapping.
// An adopted store takes the same landing as UploadSubData: the in-place write
// below would tear in-flight readers of the mapping. The pattern is expanded
// first because the landing takes the final bytes, not a repeat rule - which is
// why only a backend that actually takes them comes through here. Without that
// op the landing would memcpy the expansion into the mapping the loop below
// fills in place anyway, so a whole-arena clear would allocate a whole arena
// for nothing.
if (m_resource.IsGpuResident() && g_bufferBackendOps && g_bufferBackendOps->ResidentSubData) {
Vector<Uint8> expanded(size);
if (pattern.size == 1) {
@@ -338,16 +403,13 @@ namespace MobileGL::MG_State::GLState {
Memcpy(expanded.data() + at, pattern.data, pattern.size);
}
}
g_bufferBackendOps->ResidentSubData(*this, atOffset, {expanded.data(), size});
m_hasDefinedContent = true;
++m_changeSerial;
m_gpuWritePending = true;
LandBytesIntoResidentStore(atOffset, {expanded.data(), size});
return;
}
// A clear is ordered after all earlier GPU writes. Partial clears additionally need the
// retained shadow bytes; whole-store clears need the same synchronization before writing
// an adopted persistent mapping that the GPU may still be accessing.
// A clear is ordered after all earlier GPU writes; partial clears additionally need
// the retained shadow bytes, and a resident store the backend cannot take the bytes
// for is written in place, which needs the same synchronization the landing does.
SyncGpuWrites();
Uint8* dst = m_resource.Bytes() + atOffset;
@@ -381,22 +443,13 @@ namespace MobileGL::MG_State::GLState {
size, m_size);
src->SyncGpuWrites();
// An adopted DESTINATION takes the same GPU-timeline landing as UploadSubData;
// the in-place write below would tear in-flight readers of the mapping.
if (m_resource.IsGpuResident() && size > 0 && g_bufferBackendOps &&
g_bufferBackendOps->ResidentSubData) {
g_bufferBackendOps->ResidentSubData(*this, dstOffset,
{src->m_resource.Bytes() + srcOffset, size});
m_hasDefinedContent = true;
++m_changeSerial;
m_gpuWritePending = true;
return;
}
// The DESTINATION needs the same ordering as UploadSubData: an adopted store is
// written in place, so pending recorded GPU writes to it must retire before the
// copy lands or they would execute on top of it.
// An adopted DESTINATION takes the same landing as UploadSubData: the in-place
// write below would tear in-flight readers of the mapping, and pending recorded
// GPU writes to it must retire before the copy lands or they would execute on
// top of it.
if (m_resource.IsGpuResident()) {
SyncGpuWrites();
LandBytesIntoResidentStore(dstOffset, {src->m_resource.Bytes() + srcOffset, size});
return;
}
Memcpy(m_resource.Bytes() + dstOffset, src->m_resource.Bytes() + srcOffset, size);
NotifyContentWrite(dstOffset, size);
@@ -433,6 +486,16 @@ namespace MobileGL::MG_State::GLState {
if (m_resource.IsGpuResident()) {
return true;
}
// Adoption releases the CPU shadow, and a live mapping may BE that shadow: a
// persistent map that did not itself adopt (a FLUSH_EXPLICIT one, or a read map)
// handed the application shadow + offset, and GL keeps that pointer valid while
// the buffer is drawn with - which is exactly when this runs, on the storage
// binding walk. Freeing it under the application is a use-after-free, so a mapped
// buffer keeps the shadow model until it is unmapped; the binding that follows
// adopts then. Same rule as TryAdoptLargeStorage.
if (m_isMapped) {
return false;
}
if (m_size == 0 || g_bufferBackendOps == nullptr || g_bufferBackendOps->AcquirePersistentMap == nullptr) {
return false;
}
@@ -451,7 +514,20 @@ namespace MobileGL::MG_State::GLState {
// The app is about to look at the bytes; a shader may have rewritten them since
// the shadow was last authoritative. Also needed for a write map without an
// invalidate bit, whose staging copy is seeded from the shadow.
//
// One map shape looks at nothing: a non-persistent write map that discards the
// range it maps gets a staging copy the seeding below skips, so no reader of the
// store exists between here and the unmap. Reconciling an ADOPTED store would
// still cost the backend's full drain-and-wait (its queued landings are made
// visible to the CPU by finishing the pipeline), once per map, on exactly the
// streaming arena the adoption exists to keep cheap. The outstanding-write flag
// stays set, so the first read that DOES look at the bytes still pays for it.
const Bool discardsWhatItMaps =
(access & BufferMappingAccessBit::Write) && !(access & BufferMappingAccessBit::Persistent) &&
(access & (BufferMappingAccessBit::InvalidateRange | BufferMappingAccessBit::InvalidateBuffer));
if (!(m_resource.IsGpuResident() && discardsWhatItMaps)) {
SyncGpuWrites();
}
m_isMapped = true;
m_mappingAccess = access;
m_mappedRange = range;
@@ -81,7 +81,9 @@ namespace MobileGL {
// Contents update of [offset, offset + size) from the shadow.
void (*SubData)(BufferObject& bufferObject, SizeT offset, SizeT size) = nullptr;
// Contents update of an ADOPTED (GPU-resident) store. `data` holds the app's
// bytes; the frontend has NOT touched the resident mapping. GL orders a
// bytes, valid for the duration of the call only (a write map's staging
// store is freed the moment the unmap that lands it returns); the frontend
// has NOT touched the resident mapping. GL orders a
// glBufferSubData after already-submitted GPU reads of the store, and an
// in-place host write into the coherent mapping tears the frames still
// reading the old bytes (Minecraft patches LIVE chunk sections this way -
@@ -157,9 +159,14 @@ namespace MobileGL {
// Adopt backend host-visible coherent GPU storage as the source of truth
// (used for GPU-written targets like transform feedback capture, so
// MapBuffer/GetBufferSubData read real GPU results). No-op when already
// resident or when the backend declines.
// resident, while the buffer is mapped (adoption releases the shadow a
// mapping may have handed the application), or when the backend declines.
Bool EnsureGpuResidentStorage();
void ReleaseMemory();
// Unmap. A write map's staged bytes land in the store on the way out, unless
// the caller is about to replace that store (a respecification) and passes
// false - landing them there would copy a whole mapped range into storage
// being handed back on the next line.
void ReleaseMemory(Bool landStagedWrites = true);
void FlushMemoryRange(SizeT offset, SizeT length);
// Pushes the persistently-mapped write range to the backend; called by
@@ -234,6 +241,12 @@ namespace MobileGL {
// so this only bumps the change serial; otherwise it dispatches a backend
// SubData transfer to sync the backend's separate GPU copy.
void NotifyContentWrite(SizeT offset, SizeT size);
// The one route CPU-sourced bytes take into an ADOPTED (GPU-resident) store:
// glBufferSubData, a buffer clear, a buffer copy, and the landing of a
// non-persistent write map at unmap / explicit flush all go through it, so
// the routes cannot drift apart again. Carries no mapping asserts on
// purpose - the unmap landing runs while the buffer is still mapped.
void LandBytesIntoResidentStore(SizeT offset, DataPtr bytes);
static Uint64 AllocateLifetimeId();
+32
View File
@@ -14,6 +14,8 @@
#include <MG_Util/ShaderTranspiler/CompileEnv.h>
#include <Config.h>
#include <atomic>
namespace MobileGL::MG_State {
void Init() {
MGLOG_D("Initializing MobileGL State...");
@@ -1259,6 +1261,33 @@ namespace MobileGL::MG_State {
return m_renderbufferState.ValidateRenderbufferObject(index);
}
Uint64 GLContext::AllocateTransformFeedbackLifetimeId() {
// Starts at 1 so a zero-initialised backend slot can never carry a live object's id.
static std::atomic<Uint64> nextId{1};
return nextId.fetch_add(1, std::memory_order_relaxed);
}
GLContext::GLContext() {
// The default transform feedback object (name 0) exists from the start of the context
// (GL 4.6 core 13.2.1), but nothing binds it, so nothing else would materialise it.
// Materialising it here is what lets GetBoundTransformFeedbackLifetimeId() be a plain
// const read instead of an operator[] insert on the draw path.
m_boundTransformFeedbackLifetimeId = m_transformFeedbackObjects[0].lifetimeId;
}
Bool GLContext::HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const {
if (lifetimeId == 0) return false;
for (const auto& [name, object] : m_transformFeedbackObjects) {
if (object.lifetimeId != lifetimeId) continue;
// The bound object's span state is live in the context; its saved copy is only
// written when a bind swaps it out.
return name == m_boundTransformFeedback ? m_transformFeedbackActive : object.active;
}
// No object carries this identity any more: it was deleted, and a deleted object can
// never resume.
return false;
}
void GLContext::SaveBoundTransformFeedbackState() {
auto& object = m_transformFeedbackObjects[m_boundTransformFeedback];
for (Uint i = 0; i < MAX_TRANSFORM_FEEDBACK_BUFFERS; ++i) {
@@ -1295,6 +1324,9 @@ namespace MobileGL::MG_State {
m_transformFeedbackGeneration = object.generation;
m_transformFeedbackCapturedVertices = object.capturedVertices;
m_transformFeedbackInputPrimitives = object.inputPrimitives;
// Every route that changes which object is bound - BindTransformFeedbackObject and the
// revert a delete of the bound object performs - comes through here.
m_boundTransformFeedbackLifetimeId = object.lifetimeId;
}
void GLContext::GenTransformFeedbackNames(Uint number, Vector<Uint>& ids) {
+33 -1
View File
@@ -60,7 +60,7 @@ namespace MobileGL {
class GLContext {
public:
GLContext() = default;
GLContext();
// Error
void RecordError(ErrorCode code, UniquePtr<ErrorInfo> info);
@@ -427,6 +427,27 @@ namespace MobileGL {
void BindTransformFeedbackObject(Uint index);
void MarkTransformFeedbackObjectForDeletion(Uint index);
Uint GetBoundTransformFeedbackName() const { return m_boundTransformFeedback; }
// The bound object's never-reused identity, for a backend that keys a per-object
// resource on it. The NAME is not an identity: glGenTransformFeedbacks recycles a
// deleted one (LIFO), so a memo keyed on the name hands a brand-new object the dead
// one's slot. Cached rather than looked up on demand: the backend asks twice per
// captured draw, and an operator[] on m_transformFeedbackObjects would be an
// INSERT on the draw path - ska::flat_hash_map invalidates every reference into
// itself when it rehashes. The cache is refreshed by
// RestoreBoundTransformFeedbackState, which every bind (and the revert a delete
// performs) goes through, and seeded for the default object by the constructor.
// Never returns 0 - the counter starts at 1 so a zero-initialised memo slot cannot
// be mistaken for a live object.
Uint64 GetBoundTransformFeedbackLifetimeId() const { return m_boundTransformFeedbackLifetimeId; }
// Whether the object carrying this identity still has an OPEN capture span - one
// that glBeginTransformFeedback started and glEndTransformFeedback has not closed,
// paused or not. A backend that hands out a bounded set of per-object slots must
// never take one of these over: a paused span's counters are precisely what its
// resume reads, and GL only lets other objects capture WHILE it is paused, so the
// paused object is also the one that looks idle. An identity no live object
// carries any more (its object was deleted) answers false, which is what makes
// such a slot reclaimable.
Bool HasOpenTransformFeedbackSpan(Uint64 lifetimeId) const;
// Vertices the object captured in its last completed span; the vertex count
// glDrawTransformFeedback replays.
Uint64 GetTransformFeedbackRecordedVertices(Uint index) const;
@@ -514,6 +535,9 @@ namespace MobileGL {
GLuint m_conditionalRenderQuery = 0;
GLenum m_conditionalRenderMode = GL_NONE;
// Process-wide, never-reused. See GetBoundTransformFeedbackLifetimeId(); same
// contract as BufferObject::AllocateLifetimeId().
static Uint64 AllocateTransformFeedbackLifetimeId();
// Everything a transform feedback object owns while it is NOT the bound one.
struct TransformFeedbackObjectState {
struct SavedBufferBinding {
@@ -532,6 +556,10 @@ namespace MobileGL {
Uint64 recordedVertices = 0;
Bool hasCompletedSpan = false;
Bool everBound = false;
// Assigned by the default member initialiser, so every way an object comes into
// being - operator[] materialisation, `= {}` in Gen/Create - gets a fresh one,
// and a recycled NAME never brings the dead object's id back with it.
Uint64 lifetimeId = AllocateTransformFeedbackLifetimeId();
};
void SaveBoundTransformFeedbackState();
void RestoreBoundTransformFeedbackState();
@@ -540,6 +568,10 @@ namespace MobileGL {
UnorderedMap<Uint, TransformFeedbackObjectState> m_transformFeedbackObjects;
IndexGenerator<Uint> m_transformFeedbackNames;
Uint m_boundTransformFeedback = 0;
// Mirror of m_transformFeedbackObjects[m_boundTransformFeedback].lifetimeId, so
// the per-draw read is a load rather than a hash lookup that could insert.
// Seeded by the constructor and rewritten by RestoreBoundTransformFeedbackState.
Uint64 m_boundTransformFeedbackLifetimeId = 0;
// Map membership is object EXISTENCE, which is not the same as the answer
// glIsProgramPipeline gives: any command that needs somewhere to put state
// materializes a reserved name, so the object can exist well before it is
@@ -9,9 +9,21 @@
#include "RenderbufferObject.h"
#include <MG_Util/Metrics/TextureMetrics.h>
#include <atomic>
namespace MobileGL {
namespace MG_State {
namespace GLState {
namespace {
// Starts at 1 so a zero-initialized cache slot can never carry a live
// renderbuffer's id.
std::atomic<Uint64> g_nextRenderbufferLifetimeId{1};
}
Uint64 RenderbufferObject::AllocateLifetimeId() {
return g_nextRenderbufferLifetimeId.fetch_add(1, std::memory_order_relaxed);
}
RenderbufferObject::RenderbufferObject(Uint externalIndex) : m_externalIndex(externalIndex) {}
Uint RenderbufferObject::GetExternalIndex() const {
@@ -42,9 +42,20 @@ namespace MobileGL {
Int GetDepthSize() const;
Int GetStencilSize() const;
Int GetSamples() const;
// Globally-unique, never-reused id for THIS object's lifetime - same contract
// and same motivation as BufferObject::GetLifetimeId(),
// ProgramObject::GetLifetimeId() and VertexArrayObject::GetLifetimeId(). A
// backend that folds a renderbuffer's IDENTITY into a cache key must use this,
// never the GL name (LIFO-recycled by glGenRenderbuffers) and never the heap
// address (recycled by the allocator): both let a deleted-and-recreated
// renderbuffer answer to a dead one's cache entry.
Uint64 GetLifetimeId() const { return m_lifetimeId; }
private:
static Uint64 AllocateLifetimeId();
Uint m_externalIndex = 0;
const Uint64 m_lifetimeId = AllocateLifetimeId();
TextureInternalFormat m_internalFormat = TextureInternalFormat::RGBA;
Int m_width = 0;
Int m_height = 0;
@@ -37,6 +37,11 @@ namespace {
std::size_t ioBlockDraws = 0;
// Behavior knobs, configured per test before running the probe.
GLint maxVertexSsboBlocks = 4;
// GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE per axis, answered through glGetIntegeri_v.
// Above the GL minimums and distinct per axis, so a loader that left an initialiser in
// place or copied one axis into another is caught.
GLint maxComputeWorkGroupCount[3] = {70001, 70002, 70003};
GLint maxComputeWorkGroupSize[3] = {1500, 1501, 100};
GLint glesMajorVersion = 3;
GLint glesMinorVersion = 1;
GLint maxVertexImageUniforms = 2;
@@ -460,8 +465,20 @@ namespace {
if (data == nullptr) return;
for (int i = 0; i < 4; ++i) data[i] = GL_TRUE;
};
funcs.glGetIntegeri_v = [](GLenum, GLuint, GLint* data) {
if (data != nullptr) *data = 0;
funcs.glGetIntegeri_v = [](GLenum pname, GLuint index, GLint* data) {
if (data == nullptr) return;
*data = 0;
if (index >= 3) return;
switch (pname) {
case GL_MAX_COMPUTE_WORK_GROUP_COUNT:
*data = g_fake.maxComputeWorkGroupCount[index];
break;
case GL_MAX_COMPUTE_WORK_GROUP_SIZE:
*data = g_fake.maxComputeWorkGroupSize[index];
break;
default:
break;
}
};
funcs.glGetProgramInfoLog = [](GLuint, GLsizei bufSize, GLsizei* length, GLchar* infoLog) {
if (infoLog != nullptr && bufSize > 0) infoLog[0] = '\0';
@@ -1551,3 +1568,23 @@ TEST(LocatedIoBlockProbe, ReportsTheDefectOnlyWhenTheUnlocatedControlCarriesTheP
EXPECT_FALSE(ProbeLocatedIoBlocksLosePayload(crippled).detected);
EXPECT_EQ(g_fake.ioBlockDraws, 0u) << "an entry-point-gated probe must not draw at all";
}
// The six per-axis compute limits are the backend-owned answers that cross the MGPipe boundary
// inside MGPCaps (DynamicBackendParameters::MaxComputeWorkGroupCount/Size), so the loader has
// to take EACH axis from glGetIntegeri_v rather than leave an initialiser - or one axis's
// answer - in the other slots. The integration side (AdvertisedLimitsScenario) pins the copy
// against the live getter on both backends; this pins the driver-to-caps step on its own.
TEST(ComputeWorkGroupCapabilities, TakesEveryAxisFromTheIndexedQuery) {
const auto funcs = MakeFakeGLESFunctions();
ResetFakeDriver();
MobileGL::MG_External::GLESCapabilities caps;
ASSERT_TRUE(MobileGL::MG_Util::BackendLoader::FillInGLESCapabilities(caps, funcs));
for (int axis = 0; axis < 3; ++axis) {
EXPECT_EQ(caps.MaxComputeWorkGroupCount[axis], g_fake.maxComputeWorkGroupCount[axis]) << "axis " << axis;
EXPECT_EQ(caps.MaxComputeWorkGroupSize[axis], g_fake.maxComputeWorkGroupSize[axis]) << "axis " << axis;
}
// The initialisers are the GL 4.3 minimums and every fake answer is above them, so a
// value equal to its initialiser here would mean the query never ran.
EXPECT_GT(caps.MaxComputeWorkGroupCount[0], 65535);
EXPECT_GT(caps.MaxComputeWorkGroupSize[2], 64);
}
+900
View File
@@ -1568,6 +1568,15 @@ namespace {
int respecifyCalls = 0;
int flushCalls = 0;
Bool provideMap = true; // false => backend declines, exercising the shadow fallback
// Only recorded by the variant of the ops table that offers ResidentSubData: the
// bytes a CPU write handed the backend for a GPU-ordered landing into an adopted
// store, held back from `gpu` until a readback "retires" them.
struct ResidentWrite {
SizeT offset = 0;
Vector<Uint8> bytes;
};
Vector<ResidentWrite> residentWrites;
int readbackCalls = 0;
};
ZeroCopyMockBackend* g_zeroCopyMock = nullptr;
@@ -1605,6 +1614,39 @@ namespace {
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
};
// The same backend with the GPU-ordered landing ops a staging-ring backend offers: a
// CPU write into an adopted store is queued (the mapping is NOT written through), and
// a readback is what lands the queue before the application reads.
void ZeroCopyMock_ResidentSubData(MG_State::GLState::BufferObject&, SizeT offset, DataPtr data) {
if (!g_zeroCopyMock) return;
auto& write = g_zeroCopyMock->residentWrites.emplace_back();
write.offset = offset;
const auto* bytes = static_cast<const Uint8*>(data.data);
write.bytes.assign(bytes, bytes + data.size);
}
void ZeroCopyMock_ReadbackFromGpu(MG_State::GLState::BufferObject&) {
if (!g_zeroCopyMock) return;
++g_zeroCopyMock->readbackCalls;
for (const auto& write : g_zeroCopyMock->residentWrites) {
// Reported, not asserted: an ASSERT here would return out of the readback and
// leave the remaining landings unapplied, which reads as a different failure.
EXPECT_LE(write.offset + write.bytes.size(), g_zeroCopyMock->gpu.size());
if (write.offset + write.bytes.size() > g_zeroCopyMock->gpu.size()) continue;
Memcpy(g_zeroCopyMock->gpu.data() + write.offset, write.bytes.data(), write.bytes.size());
}
g_zeroCopyMock->residentWrites.clear();
}
const MG_State::GLState::BufferBackendOps kResidentSubDataMockOps = {
.Respecify = ZeroCopyMock_Respecify,
.SubData = ZeroCopyMock_SubData,
.ResidentSubData = ZeroCopyMock_ResidentSubData,
.FlushMappedRange = ZeroCopyMock_Flush,
.OnDestroy = ZeroCopyMock_OnDestroy,
.AcquirePersistentMap = ZeroCopyMock_AcquirePersistentMap,
.ReadbackFromGpu = ZeroCopyMock_ReadbackFromGpu,
};
struct ScopedBackendOps {
explicit ScopedBackendOps(const MG_State::GLState::BufferBackendOps* ops) {
MG_State::GLState::SetBufferBackendOps(ops);
@@ -2074,3 +2116,861 @@ TEST_F(BufferTest, RedefiningANonAdoptedBufferIsUnchanged) {
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// A NON-persistent write map of an ADOPTED store. glMapBuffer / glMapBufferRange hand
// the application a staging copy regardless of where the store lives, and GL requires
// the bytes it wrote there to be visible to every later command once glUnmapBuffer
// returns. Residency used to come only from a coherent persistent map - which writes
// in place and never has a staging copy - so the unmap simply skipped the copy-back
// for a resident store. Residency now also comes from a shader storage binding
// (EnsureGpuResidentStorage at draw time) and from large-store adoption, both of which
// an application then re-initialises through an ordinary map/write/unmap: the
// conformance suite re-seeds every SSBO that way before each draw, and every re-seed
// after the first draw was dropped on the floor. These pin the landing for each map
// shape, on the backend that writes the coherent mapping in place and on the one that
// takes the bytes for a GPU-ordered landing, plus the shadow path as the control.
namespace {
constexpr SizeT kAdoptedInts = 16;
// A buffer of kAdoptedInts sequential ints, adopted by the mock backend exactly as an
// SSBO binding does at draw time. The per-write counters are zeroed afterwards so a
// test only sees the traffic of the map it makes.
SharedPtr<MG_State::GLState::BufferObject> MakeAdoptedBuffer(ZeroCopyMockBackend& mock, GLenum target,
GLuint& buffer) {
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
EXPECT_NE(bufferObject, nullptr);
if (bufferObject == nullptr) return nullptr;
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(mock.gpu.data()));
mock.subDataCalls = 0;
mock.flushCalls = 0;
mock.respecifyCalls = 0;
return bufferObject;
}
const GLint* GpuInts(const ZeroCopyMockBackend& mock) {
return reinterpret_cast<const GLint*>(mock.gpu.data());
}
} // namespace
TEST_F(BufferTest, ANonPersistentReadWriteRangeMapOfAnAdoptedStoreLandsAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// The conformance suite's shape: the whole store, READ|WRITE, then a full rewrite.
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// A non-persistent map is a staging copy, seeded from the adopted store...
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(i));
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1000 + static_cast<GLint>(i);
// ...that the store does not see until the unmap.
EXPECT_EQ(GpuInts(mock)[0], 0);
bufferObject->ReleaseMemory();
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], 1000 + static_cast<GLint>(i)) << "int " << i;
}
EXPECT_EQ(std::memcmp(bufferObject->MappedData(), mock.gpu.data(), mock.gpu.size()), 0);
// The landing publishes the change for cached consumers...
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
// ...but dispatches no transfer op: the backend keeps no separate copy of an
// adopted store, and its flush op would only upload the mapping onto itself.
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.acquireMapCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlMapBufferWriteOnlyAndReadWriteOfAnAdoptedStoreLandAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// glMapBuffer(GL_WRITE_ONLY): the staging copy is still seeded (no invalidate bit),
// so a partial write keeps the untouched ints.
Uint64 serial = bufferObject->GetChangeSerial();
auto* writeOnly = static_cast<GLint*>(bufferObject->AcquireMemory(true, false, true));
ASSERT_NE(writeOnly, nullptr);
EXPECT_NE(static_cast<void*>(writeOnly), static_cast<void*>(mock.gpu.data()));
writeOnly[0] = 100;
writeOnly[1] = 200;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 2);
EXPECT_EQ(GpuInts(mock)[kAdoptedInts - 1], static_cast<GLint>(kAdoptedInts - 1));
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
// glMapBuffer(GL_READ_WRITE): reads see the previous landing, and the next one lands too.
serial = bufferObject->GetChangeSerial();
auto* readWrite = static_cast<GLint*>(bufferObject->AcquireMemory(true, true, true));
ASSERT_NE(readWrite, nullptr);
EXPECT_EQ(readWrite[0], 100);
EXPECT_EQ(readWrite[1], 200);
readWrite[2] = 300;
bufferObject->ReleaseMemory();
EXPECT_EQ(GpuInts(mock)[0], 100);
EXPECT_EQ(GpuInts(mock)[1], 200);
EXPECT_EQ(GpuInts(mock)[2], 300);
EXPECT_GT(bufferObject->GetChangeSerial(), serial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, AWriteMapInvalidatingAnAdoptedStoreLandsTheWholeRangeAtUnmap) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
{0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateBuffer));
ASSERT_NE(mapped, nullptr);
EXPECT_NE(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data()));
// The whole range is undefined by contract, so the application rewrites all of it.
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = -static_cast<GLint>(i) - 1;
bufferObject->ReleaseMemory();
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], -static_cast<GLint>(i) - 1) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A range map at an offset off the alignment grid: the staging store is biased by the
// offset's phase (see AcquireMemoryRange), and the landing has to read from the biased
// start and write to the mapped offset - not from data(), not to 0.
TEST_F(BufferTest, ARangeMapAtAnUnalignedOffsetOfAnAdoptedStoreLandsInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 3..6, i.e. byte offset 12 - inside the first alignment, so the bias is non-zero.
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
ASSERT_NE(range.start % MG_State::GLState::MIN_MAP_BUFFER_ALIGNMENT, 0u);
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
// Seeded from the right place...
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(mapped[i], static_cast<GLint>(kFirst + i));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 500 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// ...and landed in the right place, with everything outside the range untouched.
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 500 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// FLUSH_EXPLICIT on an adopted store: only the flushed bytes land, at the flush, and the
// unmap lands nothing more - the application promised to flush what it wanted kept.
TEST_F(BufferTest, AnExplicitFlushOfAWriteMapOfAnAdoptedStoreLandsOnlyTheFlushedBytes) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
// Ints 2..13 mapped (offset 8, off the grid again), all of them rewritten...
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 12;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 700 + static_cast<GLint>(i);
// ...but only ints 5..8 (map-relative ints 3..6) flushed.
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 4;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
const Uint64 flushSerial = bufferObject->GetChangeSerial();
EXPECT_GT(flushSerial, baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
auto expectOnlyFlushedBytesLanded = [&](const char* when) {
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const Bool flushed = i >= kFirst + kFlushFirst && i < kFirst + kFlushFirst + kFlushCount;
const GLint expected = flushed ? 700 + static_cast<GLint>(i - kFirst) : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << when << ": int " << i;
}
};
expectOnlyFlushedBytesLanded("after the flush");
bufferObject->ReleaseMemory();
expectOnlyFlushedBytesLanded("after the unmap");
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The other kind of backend: one that takes the bytes for a GPU-ordered landing instead
// of letting the frontend write the coherent mapping in place. The unmap hands it the
// mapped offset and the bias-adjusted bytes, leaves the mapping alone, and marks a GPU
// write outstanding so the next read reconciles through the readback.
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheUnmappedBytesForAGpuOrderedLanding) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 5;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 900 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
// The op got exactly the mapped range's bytes at the mapped offset...
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, range.start);
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], 900 + static_cast<GLint>(i)) << "int " << i;
// ...the mapping itself was not written through...
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the pending flag makes the next read pull the landing back first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_TRUE(mock.residentWrites.empty());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 900 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
// A second read has nothing outstanding to reconcile.
bufferObject->AcquireMemory(false, true, false);
EXPECT_EQ(mock.readbackCalls, 1);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAnExplicitlyFlushedRangeTheSameWay) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 800 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 5;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, (kFirst + kFlushFirst) * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kFlushCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
EXPECT_EQ(handed[0], 800 + static_cast<GLint>(kFlushFirst));
EXPECT_EQ(handed[1], 800 + static_cast<GLint>(kFlushFirst + 1));
EXPECT_EQ(mock.flushCalls, 0);
// The unmap of a FLUSH_EXPLICIT map adds nothing.
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The CTS idiom end to end through the GL entry points: an SSBO made resident by a
// draw, re-seeded with glMapBufferRange(READ|WRITE) + glUnmapBuffer.
TEST_F(BufferTest, MapBufferRangeAndUnmapBufferReseedAnAdoptedShaderStorageBuffer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
for (GLint pass = 1; pass <= 3; ++pass) {
auto* mapped = static_cast<GLint*>(
MapBufferRange(GL_SHADER_STORAGE_BUFFER, 0, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)),
GL_MAP_READ_BIT | GL_MAP_WRITE_BIT));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = pass * 100 + static_cast<GLint>(i);
EXPECT_TRUE(UnmapBuffer(GL_SHADER_STORAGE_BUFFER));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
EXPECT_EQ(GpuInts(mock)[i], pass * 100 + static_cast<GLint>(i)) << "pass " << pass << " int " << i;
}
}
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// The control: a store the backend declined to adopt keeps the shadow model exactly as
// before - the staging copy is written back into the shadow and the backend's flush op
// carries the range down.
TEST_F(BufferTest, ANonPersistentWriteMapOfAShadowBackedStoreStillFlushesThroughTheBackend) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
mock.flushCalls = 0;
mock.subDataCalls = 0;
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 3;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 600 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
EXPECT_EQ(mock.flushCalls, 1);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
const auto* shadow = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? 600 + static_cast<GLint>(i - kFirst)
: static_cast<GLint>(i);
EXPECT_EQ(shadow[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// ---------------------------------------------------------------------------
// The other three CPU-sourced writes that share the unmap landing's route into an
// adopted store - glBufferSubData, a clear, and a copy - on both kinds of backend: the
// one that lets the frontend write the coherent mapping in place, and the one that takes
// the bytes for a GPU-ordered landing, where the offset it is handed is the only thing
// deciding where they end up.
TEST_F(BufferTest, GlBufferSubDataIntoAnAdoptedStoreLandsInPlaceWithoutABackendTransfer) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesAGlBufferSubDataAtItsOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 4;
const GLint updated[] = {70, 71, 72};
BufferSubData(GL_ARRAY_BUFFER, static_cast<GLintptr>(kFirst * sizeof(GLint)), sizeof(updated), updated);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), sizeof(updated));
EXPECT_EQ(std::memcmp(mock.residentWrites[0].bytes.data(), updated, sizeof(updated)), 0);
// The mapping itself is left alone until the backend's ordered copy runs.
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + 3) ? updated[i - kFirst] : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, GlClearBufferSubDataRepeatsItsPatternThroughAnAdoptedStoreInPlace) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// A four-byte pattern, so the repeat - not a memset - is what fills the range.
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesTheExpandedClearPattern) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
constexpr SizeT kFirst = 5;
constexpr SizeT kCount = 6;
const GLint value = 0x0A0B0C0D;
ClearBufferSubData(GL_SHADER_STORAGE_BUFFER, GL_R32I, static_cast<GLintptr>(kFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)), GL_RED_INTEGER, GL_INT, &value);
ASSERT_EQ(GetError(), GL_NO_ERROR);
// The backend takes the FINAL bytes, so the pattern arrives already repeated.
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(handed[i], value) << "int " << i;
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kFirst && i < kFirst + kCount) ? value : static_cast<GLint>(i);
EXPECT_EQ(readBack[i], expected) << "int " << i;
}
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
namespace {
// A plain (never adopted) buffer of kAdoptedInts ints, each `bias` above its index,
// bound to `target` as the source of a copy.
GLuint MakeCopySource(GLenum target, GLint bias) {
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(target, buffer);
Vector<GLint> bytes(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) bytes[i] = bias + static_cast<GLint>(i);
BufferData(target, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), bytes.data(), GL_STATIC_DRAW);
EXPECT_EQ(GetError(), GL_NO_ERROR);
return buffer;
}
} // namespace
TEST_F(BufferTest, GlCopyBufferSubDataIntoAnAdoptedStoreLandsAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
// Deliberately different source and destination offsets: only the destination one
// may decide where the bytes land.
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
for (SizeT i = 0; i < kAdoptedInts; ++i) {
const GLint expected = (i >= kDstFirst && i < kDstFirst + kCount)
? 900 + static_cast<GLint>(kSrcFirst + i - kDstFirst)
: static_cast<GLint>(i);
EXPECT_EQ(GpuInts(mock)[i], expected) << "int " << i;
}
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, ABackendWithAResidentSubDataOpTakesACopyAtTheDestinationOffset) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint destination = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_COPY_WRITE_BUFFER, destination);
ASSERT_NE(bufferObject, nullptr);
const GLuint source = MakeCopySource(GL_COPY_READ_BUFFER, 900);
constexpr SizeT kSrcFirst = 1;
constexpr SizeT kDstFirst = 6;
constexpr SizeT kCount = 3;
CopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, static_cast<GLintptr>(kSrcFirst * sizeof(GLint)),
static_cast<GLintptr>(kDstFirst * sizeof(GLint)),
static_cast<GLsizeiptr>(kCount * sizeof(GLint)));
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.residentWrites[0].offset, kDstFirst * sizeof(GLint));
ASSERT_EQ(mock.residentWrites[0].bytes.size(), kCount * sizeof(GLint));
const auto* handed = reinterpret_cast<const GLint*>(mock.residentWrites[0].bytes.data());
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(handed[i], 900 + static_cast<GLint>(kSrcFirst + i)) << "int " << i;
}
for (SizeT i = 0; i < kAdoptedInts; ++i) EXPECT_EQ(GpuInts(mock)[i], static_cast<GLint>(i)) << "int " << i;
GLuint toDelete[] = {destination, source};
DeleteBuffers(2, toDelete);
g_zeroCopyMock = nullptr;
}
// A PERSISTENT map of an adopted store is the one write shape that needs no landing at
// all: it wrote the coherent mapping in place. Its explicit flush therefore publishes the
// change and dispatches nothing - not the backend's flush op (whose upload would be the
// mapping onto itself) and not the resident landing op (whose bytes are already there).
TEST_F(BufferTest, AnExplicitFlushOfAPersistentMapOfAnAdoptedStoreOnlyPublishesTheChange) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_SHADER_STORAGE_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
const Uint64 baseSerial = bufferObject->GetChangeSerial();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 8;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
// The application writes the store itself: the map IS the adopted memory.
EXPECT_EQ(static_cast<void*>(mapped), static_cast<void*>(mock.gpu.data() + range.start));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 400 + static_cast<GLint>(i);
constexpr SizeT kFlushFirst = 3;
constexpr SizeT kFlushCount = 2;
bufferObject->FlushMemoryRange(kFlushFirst * sizeof(GLint), kFlushCount * sizeof(GLint));
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_TRUE(bufferObject->HasDefinedContent());
// Every byte the map wrote is in the store, flushed or not - it was written there.
for (SizeT i = 0; i < kCount; ++i) {
EXPECT_EQ(GpuInts(mock)[kFirst + i], 400 + static_cast<GLint>(i)) << "int " << i;
}
const Uint64 flushSerial = bufferObject->GetChangeSerial();
bufferObject->ReleaseMemory();
EXPECT_EQ(bufferObject->GetChangeSerial(), flushSerial); // the unmap adds nothing
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_TRUE(mock.residentWrites.empty());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A flush of nothing wrote no byte, so it may not report the store as written: an
// orphaning respecification prices a "has content" store as a full-size upload.
TEST_F(BufferTest, AZeroLengthExplicitFlushOfAnAdoptedStoreLeavesItUndefined) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), nullptr,
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
ASSERT_FALSE(bufferObject->HasDefinedContent());
ASSERT_TRUE(bufferObject->EnsureGpuResidentStorage());
const Uint64 baseSerial = bufferObject->GetChangeSerial();
auto* mapped = bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)},
BufferMappingAccessBit::Write |
BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit);
ASSERT_NE(mapped, nullptr);
bufferObject->FlushMemoryRange(0, 0);
EXPECT_GT(bufferObject->GetChangeSerial(), baseSerial);
EXPECT_FALSE(bufferObject->HasDefinedContent());
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// A write map that discards the range it maps reads nothing of the store: its staging
// copy is not seeded from it. Reconciling an adopted store at map time would run the
// backend's drain-and-wait for no reader, once per map, on the streaming arena the
// adoption exists to keep cheap - so it is deferred, not dropped: the first map that DOES
// read the bytes still pays for it, and every queued landing is still applied, in order.
TEST_F(BufferTest, AWriteMapThatDiscardsWhatItMapsDoesNotReconcileAnAdoptedStoreAtMapTime) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
// An earlier write is queued for its GPU-ordered landing...
const GLint firstInt = 55;
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
ASSERT_EQ(mock.residentWrites.size(), 1u);
EXPECT_EQ(mock.readbackCalls, 0);
// ...and the map that discards its range does not wait for it.
constexpr SizeT kFirst = 8;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::InvalidateRange));
ASSERT_NE(mapped, nullptr);
EXPECT_EQ(mock.readbackCalls, 0);
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 300 + static_cast<GLint>(i);
bufferObject->ReleaseMemory();
ASSERT_EQ(mock.residentWrites.size(), 2u);
// The first read reconciles both landings, oldest first.
const auto* readBack = static_cast<const GLint*>(bufferObject->AcquireMemory(false, true, false));
EXPECT_EQ(mock.readbackCalls, 1);
EXPECT_EQ(readBack[0], firstInt);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(readBack[kFirst + i], 300 + static_cast<GLint>(i)) << "int " << i;
// The control: a map that keeps what it maps still reconciles before seeding.
BufferSubData(GL_ARRAY_BUFFER, 0, sizeof(firstInt), &firstInt);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto* seeded = static_cast<GLint*>(
bufferObject->AcquireMemoryRange(range, BufferMappingAccessBit::Read | BufferMappingAccessBit::Write));
ASSERT_NE(seeded, nullptr);
EXPECT_EQ(mock.readbackCalls, 2);
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(seeded[i], 300 + static_cast<GLint>(i)) << "int " << i;
bufferObject->ReleaseMemory();
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Adoption releases the CPU shadow, and a persistent map that did not itself adopt
// (FLUSH_EXPLICIT is excluded from adoption) handed the application a pointer into that
// shadow which GL keeps valid while the buffer is drawn with - which is exactly when a
// storage binding asks for residency. So a mapped buffer keeps the shadow model.
TEST_F(BufferTest, AStorageBindingDoesNotAdoptTheStoreWhileTheApplicationHoldsAMapping) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts);
for (SizeT i = 0; i < kAdoptedInts; ++i) initial[i] = static_cast<GLint>(i);
BufferData(GL_SHADER_STORAGE_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
const auto* shadowBase = bufferObject->MappedData();
constexpr SizeT kFirst = 2;
constexpr SizeT kCount = 4;
const Range1D range{kFirst * sizeof(GLint), (kFirst + kCount) * sizeof(GLint)};
auto* mapped = static_cast<GLint*>(bufferObject->AcquireMemoryRange(
range, BufferMappingAccessBit::Write | BufferMappingAccessBit::Persistent |
BufferMappingAccessBit::FlushExplicit));
ASSERT_NE(mapped, nullptr);
ASSERT_EQ(static_cast<const void*>(mapped), static_cast<const void*>(shadowBase + range.start));
EXPECT_FALSE(bufferObject->EnsureGpuResidentStorage());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_EQ(mock.acquireMapCalls, 0);
// The application's pointer is still the store's: it survived the binding.
EXPECT_EQ(static_cast<const void*>(bufferObject->MappedData()), static_cast<const void*>(shadowBase));
for (SizeT i = 0; i < kCount; ++i) mapped[i] = 250 + static_cast<GLint>(i);
bufferObject->FlushMemoryRange(0, kCount * sizeof(GLint));
EXPECT_EQ(mock.flushCalls, 1);
const auto* shadowInts = reinterpret_cast<const GLint*>(bufferObject->MappedData());
for (SizeT i = 0; i < kCount; ++i) EXPECT_EQ(shadowInts[kFirst + i], 250 + static_cast<GLint>(i));
// Unmapped, the next binding adopts as usual.
bufferObject->ReleaseMemory();
EXPECT_TRUE(bufferObject->EnsureGpuResidentStorage());
EXPECT_TRUE(bufferObject->IsBackendPersistentMapped());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
// Respecifying a store hands any adoption back and replaces the bytes, so the staged
// bytes of a map that is still live have nowhere to land: copying a whole mapped range
// into storage that is released on the next line is pure waste.
TEST_F(BufferTest, RespecifyingAStoreWhileItIsMappedDoesNotLandTheStagedBytesIntoIt) {
ZeroCopyMockBackend mock;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kResidentSubDataMockOps);
GLuint buffer = 0;
auto bufferObject = MakeAdoptedBuffer(mock, GL_ARRAY_BUFFER, buffer);
ASSERT_NE(bufferObject, nullptr);
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_TRUE(mock.residentWrites.empty());
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->IsBackendPersistentMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
TEST_F(BufferTest, RespecifyingAShadowBackedStoreWhileItIsMappedPushesNoRangeDown) {
ZeroCopyMockBackend mock;
mock.provideMap = false;
g_zeroCopyMock = &mock;
ScopedBackendOps scopedOps(&kZeroCopyMockOps);
GLuint buffer = 0;
GenBuffers(1, &buffer);
BindBuffer(GL_ARRAY_BUFFER, buffer);
Vector<GLint> initial(kAdoptedInts, 7);
BufferData(GL_ARRAY_BUFFER, static_cast<GLsizeiptr>(kAdoptedInts * sizeof(GLint)), initial.data(),
GL_DYNAMIC_DRAW);
ASSERT_EQ(GetError(), GL_NO_ERROR);
auto bufferObject = MG_State::pGLContext->GetBufferObject(buffer);
ASSERT_NE(bufferObject, nullptr);
mock.flushCalls = 0;
mock.subDataCalls = 0;
mock.respecifyCalls = 0;
auto* mapped = static_cast<GLint*>(
bufferObject->AcquireMemoryRange({0, kAdoptedInts * sizeof(GLint)}, BufferMappingAccessBit::Write));
ASSERT_NE(mapped, nullptr);
for (SizeT i = 0; i < kAdoptedInts; ++i) mapped[i] = 1234;
bufferObject->Respecify(kAdoptedInts * sizeof(GLint), nullptr);
EXPECT_EQ(mock.flushCalls, 0);
EXPECT_EQ(mock.subDataCalls, 0);
EXPECT_EQ(mock.respecifyCalls, 1);
EXPECT_FALSE(bufferObject->IsMapped());
EXPECT_FALSE(bufferObject->HasDefinedContent());
DeleteBuffers(1, &buffer);
g_zeroCopyMock = nullptr;
}
+17
View File
@@ -64,6 +64,15 @@ set(LINK_LIBRARIES
include(GoogleTest)
gtest_discover_tests(SanityTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
if (MSVC)
# The GL headers declare gl* as dllimport on Windows, so any test that pulls
# GetProcAddress.cpp out of the static library references __imp_gl*, which only
# resolves when the in-library entry-point definitions are part of the link.
# Applies to every test executable below, the way the DirectVulkan and
# integration test targets already do it for themselves.
add_link_options(/WHOLEARCHIVE:MobileGL_s)
endif()
add_subdirectory(BackendLoader)
add_subdirectory(Buffer)
# The heap-address-is-not-an-identity invariant the backends' per-object memos
@@ -76,6 +85,9 @@ add_subdirectory(VertexArray)
add_subdirectory(Program)
add_subdirectory(Query)
add_subdirectory(Pipeline)
# The MGPipe catalogue arithmetic: no GL context and no driver, just the .def, the seven
# generated files and the payload layouts.
add_subdirectory(Pipe)
add_subdirectory(ShaderTranspiler)
add_subdirectory(Util)
add_subdirectory(SelfTest)
@@ -85,3 +97,8 @@ add_subdirectory(Backend/DirectGLES)
if (ENABLE_INTEGRATION_TESTS)
add_subdirectory(Backend/DirectVulkan)
endif()
# The wire layer only exists in the disaggregated configuration, so its suite
# is only registered there. Nothing under MG_Remote is compiled otherwise.
if (MOBILEGL_BUILD_DISAGGREGATED)
add_subdirectory(Wire)
endif()
+28
View File
@@ -0,0 +1,28 @@
cmake_minimum_required(VERSION 3.14)
add_executable(
PipeCatalogueTest
PipeCatalogueTest.cpp
)
target_include_directories(PipeCatalogueTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/MobileGL/MG_Pipe
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
PipeCatalogueTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(PipeCatalogueTest PRIVATE /Zc:preprocessor)
endif()
include(GoogleTest)
gtest_discover_tests(PipeCatalogueTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
+289
View File
@@ -0,0 +1,289 @@
// MobileGL - MobileGL/MG_Test/Pipe/PipeCatalogueTest.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 arithmetic of the MGPipe catalogue (plan B section 4.4, appendix A). Everything here
// is cheap on purpose: it is the test that fails when PipeCalls.def and the seven generated
// files stop agreeing, and it must not need a GL context to say so.
#include <gtest/gtest.h>
#include <cstring>
#include "Includes.h"
#include <MG_Pipe/MGPipe.h>
using namespace MobileGL;
using namespace MobileGL::MG_Pipe;
namespace {
// Counting expansions of the catalogue. The Class parameter is a real enumerator, so a
// per-class count is a constant expression too.
#define MGP_COUNT_ONE(Name, Payload, Class, Flags) +1
#define MGP_COUNT_CLASS(Name, Payload, Class, Flags) +((Class) == countedClass ? 1 : 0)
constexpr SizeT kExpandedCallCount = 0 MGP_CALL_LIST(MGP_COUNT_ONE);
template <MGPipeCallClass countedClass>
constexpr SizeT ClassCount() {
return 0 MGP_CALL_LIST(MGP_COUNT_CLASS);
}
// Every payload named in the catalogue must be a memcpy-able POD, and so must every
// payload the verify comparator knows about.
#define MGP_ASSERT_CALL_PAYLOAD_POD(Name, Payload, Class, Flags) \
static_assert(std::is_trivially_copyable_v<Payload>, #Name "'s payload " #Payload " is not trivially copyable");
MGP_CALL_LIST(MGP_ASSERT_CALL_PAYLOAD_POD)
#define MGP_ASSERT_VERIFY_PAYLOAD_POD(Payload) \
static_assert(std::is_trivially_copyable_v<Payload>, #Payload " is not trivially copyable");
MGP_VERIFY_PAYLOAD_LIST(MGP_ASSERT_VERIFY_PAYLOAD_POD)
} // namespace
// The handle is the whole object model. Eight bytes, a register pair, no padding.
TEST(PipeCatalogue, HandleIsEightBytes) {
static_assert(sizeof(MGPipeHandle) == 8);
static_assert(alignof(MGPipeHandle) == 4);
static_assert(std::is_trivially_copyable_v<MGPipeHandle>);
EXPECT_EQ(sizeof(MGPipeHandle), 8u);
// The two reserved handles, and the composite band that the program-pipeline resolver
// allocates out of.
EXPECT_TRUE(MGPipeHandleIsNull(kMGPipeNullHandle));
EXPECT_FALSE(MGPipeHandleIsNull(kMGPipeDefaultFramebuffer));
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(kMGPipeFirstAllocatableSlot));
EXPECT_TRUE(MGPipeIsCompositeShaderSlot(kMGPipeShaderCsoCompositeSlotBase));
EXPECT_FALSE(MGPipeIsCompositeShaderSlot(kMGPipeShaderCsoSlotLimit));
}
// The catalogue, the number documented in its header, and the two generated tables are one
// fact stated three times. This is the test that notices when they stop being.
TEST(PipeCatalogue, EntryCountMatchesTheDocumentedCount) {
static_assert(kExpandedCallCount == MGP_CALL_LIST_DOCUMENTED_COUNT);
static_assert(kExpandedCallCount == kMGPipeCallCount);
EXPECT_EQ(kExpandedCallCount, static_cast<SizeT>(MGP_CALL_LIST_DOCUMENTED_COUNT));
EXPECT_EQ(kMGPipeCallCount, kExpandedCallCount);
}
TEST(PipeCatalogue, GeneratedTablesHoldTheWholeCatalogue) {
static_assert(ClassCount<kScreen>() == kMGPipeScreenCallCount);
static_assert(ClassCount<kScreen>() + ClassCount<kCtxCso>() + ClassCount<kCtxState>() +
ClassCount<kCtxObject>() + ClassCount<kCtxVerb>() + ClassCount<kCtxQuery>() ==
kMGPipeCallCount);
// The tables ARE their function pointers: a struct that is bigger than its call count
// has grown a member no generator knows about.
static_assert(sizeof(MGPipeScreen) == kMGPipeScreenCallCount * sizeof(void (*)()));
static_assert(sizeof(MGPipeContext) == kMGPipeContextCallCount * sizeof(void (*)()));
EXPECT_EQ(kMGPipeScreenCallCount, ClassCount<kScreen>());
EXPECT_EQ(kMGPipeContextCallCount, kMGPipeCallCount - ClassCount<kScreen>());
// The per-class counts PipeCalls.def documents in its header.
EXPECT_EQ(ClassCount<kScreen>(), 11u);
EXPECT_EQ(ClassCount<kCtxQuery>(), 8u);
EXPECT_EQ(ClassCount<kCtxCso>(), 13u);
EXPECT_EQ(ClassCount<kCtxState>(), 17u);
EXPECT_EQ(ClassCount<kCtxObject>(), 9u);
EXPECT_EQ(ClassCount<kCtxVerb>(), 13u);
}
// An uninstalled pipe is every entry null - which is exactly what "this subsystem has not
// been migrated, keep pulling" means (plan B section 4.1).
TEST(PipeCatalogue, UninstalledTablesAreAllNull) {
const void* const* screen = reinterpret_cast<const void* const*>(&gMGPipeScreen);
for (SizeT i = 0; i < kMGPipeScreenCallCount; ++i) {
EXPECT_EQ(screen[i], nullptr) << "screen entry " << i;
}
const void* const* context = reinterpret_cast<const void* const*>(&gMGPipeContext);
for (SizeT i = 0; i < kMGPipeContextCallCount; ++i) {
EXPECT_EQ(context[i], nullptr) << "context entry " << i;
}
}
// The retirement ratchet of the migration carrier (section 6.3): the constant and the
// struct must agree, and the constant only ever goes down.
TEST(PipeCatalogue, ResidualBlockSizeIsPinned) {
static_assert(sizeof(ResidualValueBlock) == MGL_RESIDUAL_BLOCK_SIZE);
EXPECT_EQ(sizeof(ResidualValueBlock), static_cast<SizeT>(MGL_RESIDUAL_BLOCK_SIZE));
// It carries the whole of both value structs today; that is what the later stages eat.
EXPECT_GE(sizeof(ResidualValueBlock), sizeof(RenderStateParameters) + sizeof(PixelStoreParameters));
}
// G3's opcode numbering is the wire protocol. Position in PipeCalls.def, 1-based, no holes.
TEST(PipeCatalogue, WireOpcodesAreThePositionsInTheCatalogue) {
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::GetCaps), 1);
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::kOpCount), kMGPipeCallCount + 1);
EXPECT_EQ(sizeof(MGPWireRecHeader), 8u);
// Every record is a multiple of the stream's 8-byte granularity, which is half of the
// applier's precondition.
EXPECT_EQ(sizeof(MGPWireRec_DrawVbo) % 8, 0u);
EXPECT_EQ(sizeof(MGPWireRec_BindRenderState) % 8, 0u);
EXPECT_EQ(sizeof(MGPWireRec_SetResidualValueState) % 8, 0u);
}
// Records are append-only. The three carriers added after the first cut - for the live
// GLFunctionsTable entries GetGpuTimestampNs, QueryCounterTimestamp and WaitSync - sit at
// the END of the list, after SetSwapInterval, so no opcode the first cut assigned has moved.
TEST(PipeCatalogue, LateArrivalsAreAppendedWithoutRenumbering) {
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::SetSwapInterval), 68);
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::QueryTimestamp), 69);
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::QueryCounter), 70);
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::FenceWaitServer), 71);
EXPECT_EQ(static_cast<Uint16>(MGPWireOp::kOpCount), 72);
}
// A well-formed record passes the applier's bounds gate. P0 has no applier, so "accepted"
// is reported as "not applied" rather than "fatal".
TEST(PipeCatalogue, ApplierAcceptsAWellFormedRecord) {
MGPWireRec_Present record{};
record.Header.Op = static_cast<Uint16>(MGPWireOp::Present);
record.Header.Size = sizeof(record);
record.Payload.FrameSerial = 42;
EXPECT_FALSE(MGPipeApplyWireRecord(MGPWireOp::Present, &record, sizeof(record), sizeof(record)));
}
// G4 reports the FIRST differing field by name, and compares field by field so that
// padding cannot produce a difference that does not exist.
TEST(PipeCatalogue, VerifyComparatorNamesTheDifferingField) {
MGPDrawInfo a{};
MGPDrawInfo b{};
const char* field = nullptr;
EXPECT_TRUE(MGPipeVerify(a, b, &field));
b.InstanceCount = 7;
EXPECT_FALSE(MGPipeVerify(a, b, &field));
EXPECT_STREQ(field, "InstanceCount");
// Padding bytes are not fields: writing to them cannot make two payloads differ.
MGPBindRenderState c{};
MGPBindRenderState d{};
c.Cso = MGPipeHandle{3, 1};
d.Cso = MGPipeHandle{3, 1};
field = nullptr;
EXPECT_TRUE(MGPipeVerify(c, d, &field));
// Nested payloads recurse, and arrays compare element-wise.
MGPFramebufferState left{};
MGPFramebufferState right{};
right.Color[3].Level = 2;
EXPECT_FALSE(MGPipeVerify(left, right, &field));
EXPECT_STREQ(field, "Color");
}
// G6's join over the backend read inventory. P0 allows unmapped rows; from P5 the gate is
// zero, so the numbers are asserted here to make a regression visible the day it happens.
TEST(PipeCatalogue, CoverageAccountsForEveryInventoryRow) {
EXPECT_EQ(kMGPipeInventoryReadPoints, 477u);
EXPECT_EQ(kMGPipeInventoryUnmapped, 0u);
EXPECT_EQ(kMGPipeInventoryMappedToCall + kMGPipeInventoryClientResolved +
kMGPipeInventoryReverseChannel + kMGPipeInventoryStructuralHandle +
kMGPipeInventoryUnmapped,
kMGPipeInventoryReadPoints);
EXPECT_GT(kMGPipeCoverageEntryCount, 0u);
}
// G5's field ids come from the same accessor list as the coverage table, and every field
// starts un-filled: reading one before its verb fills it is the poison's whole job.
TEST(PipeCatalogue, PipeInputFieldsStartUnfilled) {
EXPECT_EQ(kMGPipeInputFieldCount, 61u);
MGPipeFilledState state{};
state.CurrentVerbSerial = 1;
EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters));
state.FilledGen[static_cast<SizeT>(MGPipeInputField::GetRenderStateParameters)] = 1;
EXPECT_TRUE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters));
// The next verb makes the same value stale, which a written-once bitmap could not see.
state.CurrentVerbSerial = 2;
EXPECT_FALSE(MGPipeInputFieldIsFresh(state, MGPipeInputField::GetRenderStateParameters));
}
// G7 pins the member list the pipeline/dynamic split is derived from.
TEST(PipeCatalogue, PipelineSubsetMembersArePinned) {
EXPECT_EQ(kMGPipePipelineStateMemberCount, 24u);
EXPECT_STREQ(kMGPipePipelineStateMembers[0], "CullFaceEnabled");
EXPECT_STREQ(kMGPipePipelineStateMembers[kMGPipePipelineStateMemberCount - 1], "ColorMasks");
}
// The reverse channel is exactly ten callbacks (section 7.1).
TEST(PipeCatalogue, ReverseChannelHasTenCallbacks) {
EXPECT_EQ(kMGPipeCallbackCount, 10u);
EXPECT_EQ(sizeof(MGPipeCallbacks), kMGPipeCallbackCount * sizeof(void (*)()));
}
// The one shape that changes with the transport. In a monolith it resolves to the pointer
// it was given; with no transport installed a segment-backed span resolves to nothing
// rather than to garbage.
TEST(PipeCatalogue, HostSpanResolvesTheMonolithPointer) {
static_assert(sizeof(MGHostSpan) == 32);
const Uint8 bytes[8] = {0, 1, 2, 3, 4, 5, 6, 7};
MGHostSpan span{};
span.Ptr = bytes;
span.Size = sizeof(bytes);
span.Offset = 2;
EXPECT_EQ(MGPipeHostBytes(span), bytes + 2);
MGHostSpan staged{};
staged.Seg = 4;
staged.Size = 16;
EXPECT_EQ(gMGPipeSegmentResolver, nullptr);
EXPECT_EQ(MGPipeHostBytes(staged), nullptr);
}
// D-B8: a bound buffer range carries no inline host span. The named-UBO bytes are an
// optional second var-tail announced by HostSpanCount, so the SSBO, atomic-counter and XFB
// ranges - the majority - pay nothing for a payload whose shape is not frozen yet.
TEST(PipeCatalogue, BufferRangeCarriesNoInlineHostSpan) {
static_assert(sizeof(MGPBufferRange) == 24);
static_assert(sizeof(MGPShaderBuffers) == 32);
EXPECT_LT(sizeof(MGPBufferRange), sizeof(MGHostSpan));
// The call still declares the span it may carry, so the transport lays the tail out.
Uint32 flags = 0;
#define MGP_FLAGS_OF_SET_SHADER_BUFFERS(Name, Payload, Class, Flags) \
if (std::strcmp(#Name, "SetShaderBuffers") == 0) flags = static_cast<Uint32>(Flags);
MGP_CALL_LIST(MGP_FLAGS_OF_SET_SHADER_BUFFERS)
#undef MGP_FLAGS_OF_SET_SHADER_BUFFERS
EXPECT_EQ(flags & (kVarTail | kHostSpan), static_cast<Uint32>(kVarTail | kHostSpan));
// And the comparator sees the count that announces the tail.
MGPShaderBuffers a{};
MGPShaderBuffers b{};
const char* field = nullptr;
EXPECT_TRUE(MGPipeVerify(a, b, &field));
b.HostSpanCount = 4;
EXPECT_FALSE(MGPipeVerify(a, b, &field));
EXPECT_STREQ(field, "HostSpanCount");
}
// The buffer half of resource_subdata has no level and no box of its own: [offset, size)
// rides in UnionBox.X / UnionBox.W, and only through the two helpers, which also say where
// one record stops and the emitter has to split.
TEST(PipeCatalogue, SubDataBufferRangeRidesInTheUnionBox) {
MGPSubData record{};
record.Level = 3;
record.RegionCount = 2;
ASSERT_TRUE(MGPipeSetSubDataBufferRange(record, 4096, 65536));
EXPECT_EQ(record.UnionBox.X, 4096);
EXPECT_EQ(record.UnionBox.W, 65536u);
EXPECT_EQ(record.UnionBox.Y, 0);
EXPECT_EQ(record.UnionBox.Z, 0);
EXPECT_EQ(record.UnionBox.H, 1u);
EXPECT_EQ(record.UnionBox.D, 1u);
EXPECT_EQ(record.Level, 0);
EXPECT_EQ(record.RegionCount, 0u);
EXPECT_EQ(MGPipeSubDataBufferOffset(record), 4096u);
EXPECT_EQ(MGPipeSubDataBufferSize(record), 65536u);
// The largest range one record expresses...
ASSERT_TRUE(MGPipeSetSubDataBufferRange(record, 0x7FFFFFFFull, 0xFFFFFFFFull));
EXPECT_EQ(MGPipeSubDataBufferOffset(record), 0x7FFFFFFFull);
EXPECT_EQ(MGPipeSubDataBufferSize(record), 0xFFFFFFFFull);
// ...and beyond it the emitter splits: refused, record untouched.
EXPECT_FALSE(MGPipeSetSubDataBufferRange(record, 0x80000000ull, 1));
EXPECT_FALSE(MGPipeSetSubDataBufferRange(record, 0, 0x100000000ull));
EXPECT_EQ(MGPipeSubDataBufferOffset(record), 0x7FFFFFFFull);
EXPECT_EQ(MGPipeSubDataBufferSize(record), 0xFFFFFFFFull);
}
+11
View File
@@ -23,6 +23,17 @@ target_link_libraries(
${LINK_LIBRARIES}
)
if (MSVC)
# This test compiles library sources of its own; pulling the whole static
# library in as well (the directory-wide MG_Test link option) would define
# them twice, so that option is dropped for this one target.
get_target_property(_program_util_link_options ProgramUtilTest LINK_OPTIONS)
if (_program_util_link_options)
list(REMOVE_ITEM _program_util_link_options /WHOLEARCHIVE:MobileGL_s)
set_target_properties(ProgramUtilTest PROPERTIES LINK_OPTIONS "${_program_util_link_options}")
endif()
endif()
add_executable(
ProgramTest
ProgramTest.cpp
@@ -22,6 +22,8 @@
#include "Includes.h"
#include "Init.h"
#include <MG_Util/ShaderTranspiler/ShaderCompiler.h>
#include <MG_Util/ShaderTranspiler/SpvcSession.h>
#include <MG_Util/ShaderTranspiler/Types.h>
#include <spirv-tools/libspirv.hpp>
@@ -38,6 +40,7 @@ namespace {
constexpr Uint32 kOpTypeInt = 21;
constexpr Uint32 kOpTypeFloat = 22;
constexpr Uint32 kOpTypeArray = 28;
constexpr Uint32 kOpTypeRuntimeArray = 29;
constexpr Uint32 kOpTypeStruct = 30;
constexpr Uint32 kOpConstant = 43;
constexpr Uint32 kDecorationArrayStride = 6;
@@ -116,6 +119,18 @@ namespace {
return {elementTypeId, length};
}
// The element type id of OpTypeRuntimeArray <arrayId>, or 0 when it is not one - which is
// what a BOUNDED flattened member (an OpTypeArray) answers too, so the two shapes can be told
// apart by the pair of helpers.
Uint32 RuntimeArrayElementOf(const Vector<Uint32>& spirv, Uint32 arrayId) {
Uint32 elementTypeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode != kOpTypeRuntimeArray || wordCount < 3 || words[1] != arrayId) return;
elementTypeId = words[2];
});
return elementTypeId;
}
Bool IsUint32Type(const Vector<Uint32>& spirv, Uint32 typeId) {
Bool isUint = false;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
@@ -140,6 +155,61 @@ namespace {
return text;
}
// How many lines of a disassembly hold BOTH fragments - "OpIMul %uint" and "%uint_8", say -
// which is how the index arithmetic the pass emits is pinned without a host that could run it.
Uint32 CountLinesWith(const String& text, const String& first, const String& second) {
Uint32 count = 0;
SizeT lineStart = 0;
while (lineStart < text.size()) {
SizeT lineEnd = text.find('\n', lineStart);
if (lineEnd == String::npos) lineEnd = text.size();
const String line = text.substr(lineStart, lineEnd - lineStart);
if (line.find(first) != String::npos && line.find(second) != String::npos) ++count;
lineStart = lineEnd + 1;
}
return count;
}
// What every test of the open-ended shape asserts: the block collapsed to ONE member, which
// is a `uint[]` RUNTIME array of stride 4 rather than a bounded one, and nothing 64-bit is
// left for the demotion to find. Returns the disassembly for the arithmetic checks.
String ExpectOpenEndedWordArray(const Vector<Uint32>& output, const String& blockName) {
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, blockName);
EXPECT_NE(structId, 0u) << text;
if (structId == 0) return text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
EXPECT_EQ(members.size(), 1u) << "the block should have collapsed to one member\n" << text;
if (members.size() != 1) return text;
EXPECT_EQ(MemberOffsetsOf(output, structId), (Vector<Uint32>{0}));
const Uint32 elementTypeId = RuntimeArrayElementOf(output, members[0]);
EXPECT_NE(elementTypeId, 0u) << "member 0 is not a runtime array\n" << text;
EXPECT_EQ(ArrayShapeOf(output, members[0]).first, 0u)
<< "an open-ended block must not be given a bounded length\n"
<< text;
EXPECT_TRUE(IsUint32Type(output, elementTypeId)) << text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
return text;
}
// The compute shape every failing KHR-Single-GL45.subgroups fp64 case binds: one runtime
// array of doubles, indexed by an invocation id, read whole-element.
String OpenEndedComputeSource(const String& elementType) {
return String(R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { )") +
elementType + R"( data[]; };
void main() {
)" + elementType +
R"( value = data[gl_LocalInvocationID.x] * data[0];
result[gl_GlobalInvocationID.x] = uint(value)" +
(elementType == "double" ? String{} : String(".x")) + R"();
}
)";
}
Vector<Uint32> CompileToSpirv(GLenum stage, const String& source) {
using namespace MG_Util::ShaderTranspiler;
ShaderAttrib shaderAttrib{.shaderType = stage, .sourceStr = source};
@@ -345,3 +415,679 @@ TEST_F(FlattenFloat64StorageBlockTest, TheDemotedPathIsUnchangedByTheCapabilityA
EXPECT_EQ(explicitlyDemoted, defaulted);
EXPECT_EQ(CountFloatTypesOfWidth(defaulted, 64), 0u) << Disassemble(defaulted);
}
// ---------------------------------------------------------------------------
// The open-ended shape: a block whose last member is a runtime array. Before this was accepted
// the pass declined it and the demotion re-derived ArrayStride 4 for the now-float element, so
// `double data[]` read the application's 8-byte-stride buffer as 32-bit words - every fp64
// KHR-Single-GL45.subgroups case failed on exactly that.
// ---------------------------------------------------------------------------
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockOfDoublesBecomesAWordRuntimeArray) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Element i of the original array starts at word 2i, so the dynamic index is scaled by 2 ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the constant `data[0]` is the pair of words at 0 and 1, reached through the one
// member the block has left.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, EachDoubleVectorWidthStepsByItsOwnStride) {
struct Shape {
const char* element;
// std430 strides: dvec2 16 bytes, dvec3 and dvec4 32 bytes - i.e. 4, 8 and 8 words.
const char* strideWords;
// The last component's word offset inside one element, and the first one past it.
const char* lastComponentWords;
const char* firstWordPastIt;
};
const Shape shapes[] = {{"dvec2", "%uint_4", "%uint_2", "%uint_4"},
{"dvec3", "%uint_8", "%uint_4", "%uint_6"},
{"dvec4", "%uint_8", "%uint_6", "%uint_8"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.element);
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource(shape.element));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", shape.strideWords), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", shape.lastComponentWords), 1u) << text;
// A dvec3 is six words in a stride of eight: nothing may be read from the padding.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", shape.firstWordPastIt), 0u) << text;
}
}
TEST_F(FlattenFloat64StorageBlockTest, AFixedPrefixBeforeTheRuntimeArrayIsAddedToEveryIndex) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = head.x + uint(data[gl_LocalInvocationID.x]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
EXPECT_EQ(MemberOffsetsOf(input, inputStructId), (Vector<Uint32>{0, 16})) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// The 16-byte prefix is 4 words: element i is at word 4 + 2i.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
// And the prefix member itself is still word 0.
EXPECT_GE(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", "%uint_0 %uint_0"), 1u) << text;
}
// OpArrayLength on the flattened member counts WORDS. GL's `.length()` is the number of whole
// elements the bound range holds past the array's offset, so the count has to be rebased and
// divided - in unsigned arithmetic, and clamped rather than wrapped when the range is shorter
// than the prefix.
namespace {
// A prefix, an open-ended array of doubles, and a `.length()` of it - the one shape whose
// rewrite is an instruction SPIRV-Cross has to spell rather than plain arithmetic.
constexpr const char* kOpenEndedLengthSource = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data {
uvec4 head;
double data[];
};
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length()) + head.y;
}
)";
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfAnOpenEndedBlockIsRewrittenToAnElementCount) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
// glslang asks for member 1's length and signs the answer.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpArrayLength %uint", " 1"), 1u) << Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// Re-aimed at the one member left, ...
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 1"), 0u) << text;
// ... rebased past the 4-word prefix, clamped at zero when the range does not reach it, ...
EXPECT_EQ(CountLinesWith(text, "OpISub %uint", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpULessThan %bool", "%uint_4"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect %uint", "%uint_0"), 1u) << text;
// ... and divided by the 2-word stride, with glslang's own sign conversion still downstream.
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_2"), 1u) << text;
EXPECT_EQ(CountLinesWith(text, "OpBitcast %int", ""), 1u) << text;
}
TEST_F(FlattenFloat64StorageBlockTest, TheLengthOfABlockWithNoPrefixNeedsNoClamp) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dvec2 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data.length());
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
EXPECT_EQ(CountLinesWith(text, "OpArrayLength %uint", " 0"), 1u) << text;
// Nothing to subtract, so nothing to clamp: the word count over the 4-word stride is it.
EXPECT_EQ(CountLinesWith(text, "OpISub", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpSelect", ""), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpUDiv %uint", "%uint_4"), 1u) << text;
}
// The graphics shape of the same CTS group: a fragment stage reading a `readonly` block. The
// NonWritable the qualifier became is a promise about the whole block, and has to be on the one
// member the flattened block keeps.
TEST_F(FlattenFloat64StorageBlockTest, AReadOnlyOpenEndedBlockKeepsNonWritable) {
const String source = R"(#version 450 core
layout(binding = 4, std430) readonly buffer Buffer4 { dvec3 data[]; };
layout(location = 0) out vec4 o_color;
void main() {
uint index = uint(gl_FragCoord.x);
o_color = vec4(float(data[index].z));
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_FRAGMENT_SHADER, source);
ASSERT_FALSE(input.empty());
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer4");
EXPECT_EQ(CountLinesWith(text, "OpMemberDecorate %Buffer4 0 NonWritable", ""), 1u) << text;
// dvec3: stride 8 words, .z at +4.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_8"), 1u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_4"), 1u) << text;
}
// Writing through an open-ended block, which no CTS case does but any shader may: the store
// is decomposed into the same words the load would have read, so the bytes the application
// gets back are the ones GL says it wrote.
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockIsWrittenThroughTheSameWords) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 1) buffer Data { double data[]; };
void main() {
data[gl_LocalInvocationID.x] = double(gl_LocalInvocationID.y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// One dynamic index, scaled to the 2-word element ...
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 1u) << text;
// ... and the double left as exactly two word stores, nothing wider.
EXPECT_EQ(CountLinesWith(text, "OpStore", ""), 2u) << text;
EXPECT_EQ(CountLinesWith(text, "OpAccessChain %_ptr_StorageBuffer_uint", ""), 2u) << text;
}
// The exact compute shader KHR-Single-GL45.subgroups.arithmetic.compute.subgroupmul_double
// generates, so the CTS shape is pinned as it is and not as a paraphrase of it.
TEST_F(FlattenFloat64StorageBlockTest, TheSubgroupMulDoubleComputeShaderIsFlattened) {
const String source = R"(#version 450
#extension GL_KHR_shader_subgroup_arithmetic: enable
#extension GL_KHR_shader_subgroup_ballot: enable
layout (local_size_x = 16, local_size_y = 1, local_size_z = 1) in;
layout(binding = 0, std430) buffer Buffer0
{
uint result[];
};
layout(binding = 1, std430) buffer Buffer1
{
double data[];
};
void main (void)
{
uvec3 globalSize = gl_NumWorkGroups * gl_WorkGroupSize;
highp uint offset = globalSize.x * ((globalSize.y * gl_GlobalInvocationID.z) + gl_GlobalInvocationID.y) + gl_GlobalInvocationID.x;
uvec4 mask = subgroupBallot(true);
uint start = 0u, end = gl_SubgroupSize;
double ref = double(1);
uint tempResult = 0u;
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult = (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x1u : 0u;
if (1u == (gl_SubgroupInvocationID % 2u))
{
mask = subgroupBallot(true);
ref = double(1);
for (uint index = start; index < end; index++)
{
if (subgroupBallotBitExtract(mask, index))
{
ref = ref * data[index];
}
}
tempResult |= (abs(ref - subgroupMul(data[gl_SubgroupInvocationID])) < 0.00001) ? 0x2u : 0u;
}
else
{
tempResult |= 0x2u;
}
result[offset] = tempResult;
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Buffer1");
// Four reads of the array, each scaled to the 2-word element.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2"), 4u) << text;
// The result block holds no double and is not the pass's business.
const Uint32 resultStructId = StructIdNamed(output, "Buffer0");
ASSERT_NE(resultStructId, 0u) << text;
const Vector<Uint32> resultMembers = MemberTypesOf(output, resultStructId);
ASSERT_EQ(resultMembers.size(), 1u);
EXPECT_TRUE(IsUint32Type(output, RuntimeArrayElementOf(output, resultMembers[0]))) << text;
}
// A runtime array whose element is a MATRIX. The member's own MatrixStride and RowMajor
// decorations describe those elements, so a row-major one has to be declined - its columns are
// not contiguous, and addressing it in column order against a row-major buffer would be silently
// wrong bytes rather than a refusal. The column-major twin must flatten, stepping by the
// element's stride and then by the column's.
TEST_F(FlattenFloat64StorageBlockTest, ARowMajorMatrixRuntimeArrayIsLeftToTheDemotion) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1, row_major) buffer Data { dmat4 data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x][1][2]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
// The premise: glslang really did mark the member row-major.
EXPECT_EQ(CountLinesWith(Disassemble(input), "OpMemberDecorate %Data 0 RowMajor", ""), 1u)
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// Still a runtime array of matrices - narrowed to fp32 by the demotion, not re-addressed.
EXPECT_NE(DecorationValueOf(output, members[0], kDecorationArrayStride), 4u)
<< "a row-major matrix element must not have been flattened into words\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, AColumnMajorMatrixRuntimeArrayStepsByItsColumnStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { dmat2x4 data[]; };
void main() {
dvec4 column = data[gl_LocalInvocationID.x][1];
result[gl_GlobalInvocationID.x] = uint(column.w);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// dmat2x4: two columns of dvec4, column stride 32 bytes, so one element is 64 bytes -
// 16 words - and column 1 starts 8 words into it.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_16"), 1u) << text;
// Exactly one +8: the column's own offset inside the element. A second would mean a word
// past the column was being addressed off that same base.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_8"), 1u) << text;
// All eight words of that column are read - the last of its four doubles ends at +7 ...
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_7"), 1u) << text;
// ... and the column that was not asked for is not touched: nothing is read at +9 or past.
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_9"), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIAdd %uint", "%uint_10"), 0u) << text;
}
// A runtime array whose element is a STRUCT: the same walk, and the same decline test, as a
// bounded array of them - a shape no other open-ended case reaches.
TEST_F(FlattenFloat64StorageBlockTest, AStructRuntimeArrayStepsByItsElementStride) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Pair { double a; float b; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Pair data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].a) +
uint(data[gl_LocalInvocationID.x].b);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// std430 rounds `{ double a; float b; }` up to its 8-byte alignment: 16 bytes, 4 words,
// with `b` two words in.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_4"), 2u) << text;
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_2"), 1u) << text;
}
// The leaf cap bounds ONE load or store, not a member's size: a block whose element is far too
// big to expand whole is still flattened while every access to it names a scalar. Declining it
// would leave the application's 8-byte-stride doubles to the demotion's re-derived stride 4 -
// the exact defect the open-ended shape exists to avoid.
TEST_F(FlattenFloat64StorageBlockTest, AHugeRuntimeArrayElementIsStillFlattenedWhenAccessesAreSmall) {
const String source = R"(#version 430 core
layout(local_size_x = 16) in;
struct Big { dvec4 v[300]; };
layout(std430, binding = 0) buffer Sink { uint result[]; };
layout(std430, binding = 1) buffer Data { Big data[]; };
void main() {
result[gl_GlobalInvocationID.x] = uint(data[gl_LocalInvocationID.x].v[3].y);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
// 300 dvec4 of 32 bytes each: 9600 bytes, 2400 words per element - 1200 scalars, well past
// the per-access cap that a whole-element load would have to respect and this never does.
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", "%uint_2400"), 1u) << text;
// v[3].y is 3 * 8 + 2 = 26 words into the element.
EXPECT_GE(CountLinesWith(text, "OpIAdd %uint", "%uint_26"), 1u) << text;
}
// The flatten preserves a byte layout ACROSS a narrowing; where the backend consumes 64-bit
// floats itself there is nothing to preserve, and the open-ended block has to keep its runtime
// array of doubles exactly as the driver would lay it out.
TEST_F(FlattenFloat64StorageBlockTest, TheNativePathLeavesAnOpenEndedBlockAndItsDoublesAlone) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("double"));
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, true, true, true));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
EXPECT_NE(RuntimeArrayElementOf(output, members[0]), 0u)
<< "the member should still be a runtime array\n"
<< text;
EXPECT_EQ(DecorationValueOf(output, members[0], kDecorationArrayStride), 8u)
<< "the array must keep the 8-byte stride the application bound\n"
<< text;
EXPECT_GT(CountFloatTypesOfWidth(output, 64), 0u)
<< "nothing narrows here, so the doubles must survive\n"
<< text;
}
// The other backend prints the flattened module through SPIRV-Cross: an open-ended `uint[]`
// member has to come out as ESSL that names no 64-bit type. The `.length()` shape is here too,
// because the OpArrayLength the rewrite re-issues is the one instruction in it whose ESSL
// spelling is not plain arithmetic - if that backend ever refused it on the flattened member,
// a DirectGLES shader asking an fp64 buffer its length would fail at link and nowhere else.
namespace {
String TranspileToEssl(const Vector<Uint32>& spirv) {
using namespace MG_Util::ShaderTranspiler;
SpvcSession session(spirv, SessionUsageBit::Transpile);
spvc_compiler_options options;
EXPECT_EQ(session.CreateOptions(&options), SPVC_SUCCESS);
spvc_compiler_options_set_uint(options, SPVC_COMPILER_OPTION_GLSL_VERSION, 320);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_ES, SPVC_TRUE);
spvc_compiler_options_set_bool(options, SPVC_COMPILER_OPTION_GLSL_VULKAN_SEMANTICS, SPVC_FALSE);
EXPECT_EQ(session.SetOptions(options), SPVC_SUCCESS);
auto essl = ShaderCompiler::DecompileShader(session);
EXPECT_TRUE(essl) << (essl ? String{} : essl.error().log);
return essl ? *essl : String{};
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, OpenEndedComputeSource("dvec4"));
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
EXPECT_NE(essl.find("uint"), String::npos) << essl;
}
TEST_F(FlattenFloat64StorageBlockTest, TheRewrittenLengthCanBeEmittedAsEssl) {
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, kOpenEndedLengthSource);
ASSERT_FALSE(input.empty());
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
ExpectOpenEndedWordArray(output, "Data");
const String essl = TranspileToEssl(output);
ASSERT_FALSE(essl.empty());
EXPECT_EQ(essl.find("double"), String::npos) << essl;
EXPECT_EQ(essl.find("dvec"), String::npos) << essl;
// The length survived as a length - it was not folded away or dropped on the floor.
EXPECT_NE(essl.find(".length()"), String::npos) << essl;
}
// ---------------------------------------------------------------------------
// The gate from the other side: a runtime array anywhere but the block's own last member is a
// shape GLSL cannot spell and this pass does not describe. SPIR-V can spell it, so both are
// hand-written, and both are invalid Vulkan SPIR-V - the chain runs without its validator here,
// which is also why neither can be a validation-failure count.
// ---------------------------------------------------------------------------
namespace {
// `buffer Odd { double data[]; uint tail; }`, the runtime array FIRST.
const char* kRuntimeArrayNotLastAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Odd "Odd"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpDecorate %Odd Block
OpMemberDecorate %Odd 0 Offset 0
OpMemberDecorate %Odd 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Odd = OpTypeStruct %_runtimearr_double %uint
%_ptr_StorageBuffer_Odd = OpTypePointer StorageBuffer %Odd
%var = OpVariable %_ptr_StorageBuffer_Odd StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
// `struct Inner { double data[]; }; buffer Outer { uint head; Inner inner; }`: the runtime
// array IS last, but of a member rather than of the block.
const char* kRuntimeArrayNestedAsm = R"(
OpCapability Shader
OpCapability Float64
OpMemoryModel Logical GLSL450
OpEntryPoint GLCompute %main "main"
OpExecutionMode %main LocalSize 1 1 1
OpName %Outer "Outer"
OpName %Inner "Inner"
OpName %var ""
OpDecorate %_runtimearr_double ArrayStride 8
OpMemberDecorate %Inner 0 Offset 0
OpDecorate %Outer Block
OpMemberDecorate %Outer 0 Offset 0
OpMemberDecorate %Outer 1 Offset 8
OpDecorate %var Binding 0
OpDecorate %var DescriptorSet 0
%void = OpTypeVoid
%3 = OpTypeFunction %void
%uint = OpTypeInt 32 0
%int = OpTypeInt 32 1
%int_0 = OpConstant %int 0
%int_1 = OpConstant %int 1
%double = OpTypeFloat 64
%double_2 = OpConstant %double 2
%_runtimearr_double = OpTypeRuntimeArray %double
%Inner = OpTypeStruct %_runtimearr_double
%Outer = OpTypeStruct %uint %Inner
%_ptr_StorageBuffer_Outer = OpTypePointer StorageBuffer %Outer
%var = OpVariable %_ptr_StorageBuffer_Outer StorageBuffer
%_ptr_StorageBuffer_double = OpTypePointer StorageBuffer %double
%main = OpFunction %void None %3
%5 = OpLabel
%6 = OpAccessChain %_ptr_StorageBuffer_double %var %int_1 %int_0 %int_1
OpStore %6 %double_2
OpReturn
OpFunctionEnd
)";
Vector<Uint32> AssembleUnchecked(const char* asmText) {
spvtools::SpirvTools tools(SPV_ENV_VULKAN_1_1);
Vector<Uint32> module;
EXPECT_TRUE(tools.Assemble(asmText, &module));
return module;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, ARuntimeArrayThatIsNotTheBlocksLastMemberIsLeftToTheDemotion) {
struct Shape {
const char* asmText;
const char* blockName;
};
const Shape shapes[] = {{kRuntimeArrayNotLastAsm, "Odd"}, {kRuntimeArrayNestedAsm, "Outer"}};
for (const Shape& shape : shapes) {
SCOPED_TRACE(shape.blockName);
const Vector<Uint32> input = AssembleUnchecked(shape.asmText);
ASSERT_FALSE(input.empty());
Vector<Uint32> output;
ASSERT_TRUE(ShaderCompiler::SanitizeAndOptimizeBinary(input, output, false, false));
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
// Declined: both members are still there, and the demotion narrowed them the old way.
const Uint32 structId = StructIdNamed(output, shape.blockName);
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u) << text;
EXPECT_EQ(CountFloatTypesOfWidth(output, 64), 0u) << text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
}
// ---------------------------------------------------------------------------
// The front end declares types in first-use order, so a block that is the first thing the
// shader touches is declared before the module's `uint` - and the flattened member is an array
// OF `uint`. For an OPEN-ENDED block the pass moves that operand-less type up in front of the
// block rather than declining, so that where a buffer of doubles stands in the shader does not
// decide whether its bytes survive. A BOUNDED block in the same position keeps the decline it
// has always had: widening that is a change to a path this fix does not need, and the pair below
// pins both halves.
// ---------------------------------------------------------------------------
namespace {
// The position of <id>'s declaration in instruction order, or 0 when it has none.
Uint32 DeclarationIndexOf(const Vector<Uint32>& spirv, Uint32 id) {
Uint32 index = 0;
Uint32 found = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
++index;
if (found != 0 || wordCount < 2) return;
// Every OpType* has its result id in word 1; that is all this is asked about.
if (opcode >= kOpTypeInt && opcode <= kOpTypeStruct && words[1] == id) found = index;
});
return found;
}
Uint32 Uint32TypeIdOf(const Vector<Uint32>& spirv) {
Uint32 typeId = 0;
ForEachInstruction(spirv, [&](Uint32 opcode, const Uint32* words, Uint32 wordCount) {
if (opcode == kOpTypeInt && wordCount >= 4 && words[2] == 32u && words[3] == 0u) typeId = words[1];
});
return typeId;
}
} // namespace
TEST_F(FlattenFloat64StorageBlockTest, AnOpenEndedBlockDeclaredBeforeTheModulesUintIsStillFlattened) {
// The block is the first thing main touches, and nothing before it needs a uint - not even
// an array length, which is a uint constant and would declare one.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Data { double data[]; };
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
result[0] = float(data[0] + data[1]);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Data");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
// The premise: the module's uint really is declared after the block (or not at all).
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = ExpectOpenEndedWordArray(output, "Data");
const Uint32 structId = StructIdNamed(output, "Data");
ASSERT_NE(structId, 0u) << text;
const Vector<Uint32> members = MemberTypesOf(output, structId);
ASSERT_EQ(members.size(), 1u) << text;
// And the uint now stands in front of the block it is an element of.
EXPECT_LT(DeclarationIndexOf(output, RuntimeArrayElementOf(output, members[0])),
DeclarationIndexOf(output, structId))
<< text;
}
TEST_F(FlattenFloat64StorageBlockTest, ABoundedBlockDeclaredBeforeTheModulesUintIsLeftToTheDemotion) {
// The same position, a bounded block: this is the shape that has always been declined, and
// it stays declined - its members and the demotion's own repacking come through untouched.
const String source = R"(#version 430 core
layout(local_size_x = 1) in;
layout(std430, binding = 0) buffer Wide {
double data0;
dvec2 data1;
} g_wide;
layout(std430, binding = 1) buffer Sink { float result[]; };
void main() {
double sum = g_wide.data0 + g_wide.data1.y;
result[0] = float(sum);
}
)";
const Vector<Uint32> input = CompileToSpirv(GL_COMPUTE_SHADER, source);
ASSERT_FALSE(input.empty());
const Uint32 inputStructId = StructIdNamed(input, "Wide");
ASSERT_NE(inputStructId, 0u);
const Uint32 inputUintId = Uint32TypeIdOf(input);
ASSERT_TRUE(inputUintId == 0 ||
DeclarationIndexOf(input, inputUintId) > DeclarationIndexOf(input, inputStructId))
<< "this shader was meant to declare the block before any uint\n"
<< Disassemble(input);
const Vector<Uint32> output = Sanitize(input);
ASSERT_FALSE(output.empty());
const String text = Disassemble(output);
const Uint32 structId = StructIdNamed(output, "Wide");
ASSERT_NE(structId, 0u) << text;
EXPECT_EQ(MemberTypesOf(output, structId).size(), 2u)
<< "a bounded block in this position must keep the decline it shipped with\n"
<< text;
EXPECT_EQ(CountLinesWith(text, "OpIMul %uint", ""), 0u)
<< "nothing should have been re-addressed\n"
<< text;
}
+25
View File
@@ -45,6 +45,31 @@ endif()
include(GoogleTest)
gtest_discover_tests(ObjectLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
add_executable(
TransformFeedbackLifetimeIdTest
TransformFeedbackLifetimeIdTest.cpp
)
target_include_directories(TransformFeedbackLifetimeIdTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/xxHash
${MGL_ROOT}/3rdparty/Vulkan-Headers/include
${MGL_ROOT}/3rdparty/SPIRV-Reflect
)
target_link_libraries(
TransformFeedbackLifetimeIdTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
if (MSVC)
target_compile_options(TransformFeedbackLifetimeIdTest PRIVATE /Zc:preprocessor)
endif()
gtest_discover_tests(TransformFeedbackLifetimeIdTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
add_executable(
RenderStateTest
RenderStateTest.cpp
@@ -32,6 +32,7 @@
#include "Includes.h"
#include <MG_State/GLState/BufferState/BufferObject.h>
#include <MG_State/GLState/RenderbufferState/RenderbufferObject.h>
#include <MG_State/GLState/VertexArrayState/VertexArrayObject.h>
using namespace MobileGL;
@@ -138,3 +139,20 @@ TEST(ObjectLifetimeIdTest, LiveVertexArrayObjectsHaveDistinctLifetimeIds) {
TEST(ObjectLifetimeIdTest, LiveBufferObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::BufferObject>("BufferObject");
}
// The renderbuffer had no lifetime id at all until plan B §11 P0 gave it one: it is
// the one FBO attachment source whose identity a backend twin registry can only have
// keyed on the heap address or the GL name, both of which recycle.
TEST(ObjectLifetimeIdTest, RenderbufferObjectAtARecycledAddressCarriesAFreshLifetimeId) {
using MG_State::GLState::RenderbufferObject;
const int reuseCount = ProbeLifetimeIdAcrossAddressReuse<RenderbufferObject>("RenderbufferObject");
if (reuseCount == 0) {
GTEST_SKIP() << "inconclusive, not proven: this allocator never handed the same address back across 64 "
"construct/destroy rounds, so the recycled-address case was never exercised";
}
RecordProperty("address_reuses_observed", reuseCount);
}
TEST(ObjectLifetimeIdTest, LiveRenderbufferObjectsHaveDistinctLifetimeIds) {
ExpectDistinctIdsWhileBothAlive<MG_State::GLState::RenderbufferObject>("RenderbufferObject");
}
@@ -0,0 +1,143 @@
// MobileGL - MobileGL/MG_Test/State/TransformFeedbackLifetimeIdTest.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
//
// D21 (plan B v2 §4.7.3): DirectVulkan hands every transform feedback object one of sixteen
// counter-buffer groups, and the group carries that span's resume offset. The map was keyed on
// the GL NAME, which glGenTransformFeedbacks recycles the moment the object is deleted, so an
// object created on a recycled name was served the DEAD object's group together with its
// m_xfbCountersValid / m_xfbLastSeenGeneration entries.
//
// The transform feedback object is a plain struct inside a map rather than a heap object, so the
// reuse to defend against is the NAME's, not an address's - which is why these cases live here
// and not in ObjectLifetimeIdTest.cpp with the heap-allocated object types. Keeping them in
// their own translation unit also keeps the D21 commit textually independent of the rest of the
// branch, which plan B §10.4-5 asks for so it can be cherry-picked to dev on its own.
//
// The second case pins the OTHER half of the backend contract: a bounded slot table has to be
// able to tell an object whose span is still open (and may yet resume) from one whose span is
// closed or whose object is gone.
#include <gtest/gtest.h>
#include "Includes.h"
#include "Init.h"
#include <MG_State/GLState/Core.h>
using namespace MobileGL;
namespace {
MG_State::GLState::GLContext& FreshContext() {
MobileGL::Initialize();
MG_State::pGLContext = MakeUnique<MG_State::GLState::GLContext>();
return *MG_State::pGLContext;
}
} // namespace
TEST(TransformFeedbackLifetimeIdTest, AnObjectAtARecycledNameCarriesAFreshLifetimeId) {
auto& context = FreshContext();
// Before anything is bound. The default object exists from the start of the context, and the
// identity has to exist with it: a backend reading 0 here would match every FREE slot in its
// table without ever claiming one, which is the same bug this id was added to remove.
EXPECT_NE(context.GetBoundTransformFeedbackLifetimeId(), 0u)
<< "the default transform feedback object has no identity until something binds it";
Vector<Uint> names;
context.GenTransformFeedbackNames(1, names);
ASSERT_EQ(names.size(), 1u);
const Uint name = names[0];
ASSERT_NE(name, 0u);
context.BindTransformFeedbackObject(name);
const Uint64 firstId = context.GetBoundTransformFeedbackLifetimeId();
EXPECT_NE(firstId, 0u) << "a live transform feedback object answered to id 0, which is the value a "
"zero-initialised backend slot already carries";
// The default object is a different object and must not share the id.
context.BindTransformFeedbackObject(0);
EXPECT_NE(context.GetBoundTransformFeedbackLifetimeId(), firstId)
<< "the default transform feedback object shares an identity with a generated one";
// Deleting while bound reverts to the default object (GL 4.6 core 13.2.1), which is the
// shape the backend sees; delete from there anyway so the test does not depend on it.
context.BindTransformFeedbackObject(name);
context.MarkTransformFeedbackObjectForDeletion(name);
Vector<Uint> reborn;
context.GenTransformFeedbackNames(1, reborn);
ASSERT_EQ(reborn.size(), 1u);
if (reborn[0] != name) {
GTEST_SKIP() << "inconclusive, not proven: the name generator did not hand the deleted name back, so "
"the recycled-name case was never exercised";
}
context.BindTransformFeedbackObject(reborn[0]);
EXPECT_NE(context.GetBoundTransformFeedbackLifetimeId(), firstId)
<< "a transform feedback object created on a recycled name reports the DEAD object's lifetime id - "
"DirectVulkan would hand it the dead span's counter slot, and with it that span's resume state";
}
// The predicate DirectVulkan's slot table asks before it takes a group over. The case that
// matters is the middle one: object A is PAUSED and another object is bound and capturing, so A
// looks completely idle to a least-recently-used rule while being exactly the object whose
// counters must survive.
TEST(TransformFeedbackLifetimeIdTest, APausedSpanStaysOpenWhileAnotherObjectCaptures) {
auto& context = FreshContext();
Vector<Uint> names;
context.GenTransformFeedbackNames(2, names);
ASSERT_EQ(names.size(), 2u);
const Uint nameA = names[0];
const Uint nameB = names[1];
context.BindTransformFeedbackObject(nameA);
const Uint64 idA = context.GetBoundTransformFeedbackLifetimeId();
EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idA)) << "an object that never began a span reads as open";
context.BeginTransformFeedback(GL_POINTS, nullptr);
EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA));
// Pausing is what makes interleaving legal (ARB_transform_feedback2); the span is still open.
context.SetTransformFeedbackPaused(true);
EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA));
// Now the shape the slot table sees: B is bound and capturing, A is paused and untouched.
context.BindTransformFeedbackObject(nameB);
const Uint64 idB = context.GetBoundTransformFeedbackLifetimeId();
EXPECT_NE(idB, idA);
context.BeginTransformFeedback(GL_POINTS, nullptr);
EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA))
<< "a paused span stopped reading as open the moment another object was bound - a backend "
"reclaiming slots by 'is this owner still going' would take A's counters away";
EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idB));
context.EndTransformFeedback();
EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idB)) << "a closed span still reads as open";
EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idA));
// A closes its own span; its slot becomes reclaimable.
context.BindTransformFeedbackObject(nameA);
context.EndTransformFeedback();
EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idA));
// A deleted object can never resume, so its identity must not hold a slot either.
context.BindTransformFeedbackObject(nameB);
context.BeginTransformFeedback(GL_POINTS, nullptr);
EXPECT_TRUE(context.HasOpenTransformFeedbackSpan(idB));
context.MarkTransformFeedbackObjectForDeletion(nameB);
EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(idB))
<< "the identity of a deleted transform feedback object still claims an open span, so its counter "
"group would be pinned for the life of the context";
// Identities the context never issued, and the free-slot sentinel, are not open spans.
EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(0));
EXPECT_FALSE(context.HasOpenTransformFeedbackSpan(~0ull));
}
+19
View File
@@ -35,6 +35,25 @@ target_link_libraries(
${LINK_LIBRARIES}
)
# The MGPipe boundary counters: the enable latch, the byte/call/gate arithmetic, the
# payload histogram's bucketing and the two report formats. No GL context, no driver.
add_executable(
PipeStatsTest
PipeStatsTest.cpp
)
target_include_directories(PipeStatsTest PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
)
target_link_libraries(
PipeStatsTest PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
include(GoogleTest)
gtest_discover_tests(JobNodeTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(LogLevelTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
gtest_discover_tests(PipeStatsTest DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
+287
View File
@@ -0,0 +1,287 @@
// MobileGL - MobileGL/MG_Test/Util/PipeStatsTest.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 MGPipe boundary counters (plan B section 11 P0, corollary in section 2.3.1).
// No GL context and no driver: the module is arithmetic over a fixed set of counters,
// which is exactly what has to be pinned before anyone reads a number off a device.
#include <gtest/gtest.h>
#include <Config.h>
#include <MG_Util/Metrics/PipeStats.h>
#include <cstdio>
#include <fstream>
#include <sstream>
namespace {
namespace PS = MobileGL::MG_Util::PipeStats;
using MobileGL::String;
using MobileGL::Uint32;
using MobileGL::Uint64;
class PipeStatsTest : public ::testing::Test {
protected:
void SetUp() override {
PS::ResetForTesting();
PS::SetEnabledForTesting(true);
}
void TearDown() override {
PS::SetEnabledForTesting(false);
PS::ResetForTesting();
}
};
// The off latch is the whole cost argument: every counting site in the two backends is
// written as `if (Enabled()) ...`, so a false latch has to mean "nothing is counted".
TEST_F(PipeStatsTest, EnabledLatchIsTheOnlyGate) {
PS::SetEnabledForTesting(false);
EXPECT_FALSE(PS::Enabled());
PS::SetEnabledForTesting(true);
EXPECT_TRUE(PS::Enabled());
}
TEST_F(PipeStatsTest, ByteClassesAccumulateIndependently) {
PS::AddBytes(PS::ByteClass::StageBuffer, 100);
PS::AddBytes(PS::ByteClass::StageBuffer, 40);
PS::AddBytes(PS::ByteClass::StageTexture, 7);
EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageBuffer), 140u);
EXPECT_EQ(PS::FrameBytes(PS::ByteClass::StageBuffer), 140u);
EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageTexture), 7u);
// Every other class untouched, the residual-value-block placeholder included.
EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboGlobal), 0u);
EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageUboNamed), 0u);
EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageIndirectCmd), 0u);
EXPECT_EQ(PS::TotalBytes(PS::ByteClass::ResidualValueBlock), 0u);
}
// The frame accumulator is what feeds TracyPlot; the run total is what feeds the JSON
// dump. A present must clear the first and keep the second.
TEST_F(PipeStatsTest, PresentClearsTheFrameButKeepsTheTotal) {
PS::AddBytes(PS::ByteClass::StageTexture, 512);
PS::AddCalls(PS::CallClass::Draws, 3);
PS::CountGate(PS::Gate::EsprytRenderState, /*hit=*/true);
PS::OnPresent();
EXPECT_EQ(PS::FrameBytes(PS::ByteClass::StageTexture), 0u);
EXPECT_EQ(PS::FrameCalls(PS::CallClass::Draws), 0u);
EXPECT_EQ(PS::TotalBytes(PS::ByteClass::StageTexture), 512u);
EXPECT_EQ(PS::TotalCalls(PS::CallClass::Draws), 3u);
EXPECT_EQ(PS::TotalGateHits(PS::Gate::EsprytRenderState), 1u);
EXPECT_EQ(PS::FrameCount(), 1u);
}
TEST_F(PipeStatsTest, InitLatchesTheSummaryPeriodFromTheConfigAndNeverKeepsZero) {
// The device retrace harness never reaches the teardown dump, so the summary
// cadence is the only way a short fixture yields numbers at all: it must follow
// MOBILEGL_PIPE_STATS_PERIOD, and a zero must fall back rather than divide.
const Uint32 saved = MobileGL::MG_Config::Features.PipeStatsPeriod;
MobileGL::MG_Config::Features.PipeStatsPeriod = 7;
PS::Init();
EXPECT_EQ(PS::SummaryFramePeriod(), 7u);
MobileGL::MG_Config::Features.PipeStatsPeriod = 0;
PS::Init();
EXPECT_EQ(PS::SummaryFramePeriod(), PS::kDefaultSummaryFramePeriod);
MobileGL::MG_Config::Features.PipeStatsPeriod = saved;
PS::Init();
}
TEST_F(PipeStatsTest, GateHitsAndMissesAreSeparateCounters) {
for (Uint32 i = 0; i < 5; ++i) {
PS::CountGate(PS::Gate::MagmaPipelineMemo, /*hit=*/true);
}
PS::CountGate(PS::Gate::MagmaPipelineMemo, /*hit=*/false);
PS::CountGate(PS::Gate::MagmaDrawFastPath, /*hit=*/false);
EXPECT_EQ(PS::TotalGateHits(PS::Gate::MagmaPipelineMemo), 5u);
EXPECT_EQ(PS::TotalGateMisses(PS::Gate::MagmaPipelineMemo), 1u);
EXPECT_EQ(PS::TotalGateHits(PS::Gate::MagmaDrawFastPath), 0u);
EXPECT_EQ(PS::TotalGateMisses(PS::Gate::MagmaDrawFastPath), 1u);
}
// Bucket 0 is "no payload"; bucket n>0 is [2^(n-1), 2^n). The placeholder histogram is
// the SEG_CMD sizing input (section 4.5.7), so its bucketing is pinned now rather than
// when a generator first calls it.
TEST_F(PipeStatsTest, PayloadHistogramBucketsByPowerOfTwo) {
PS::RecordDrawPayloadBytes(0);
PS::RecordDrawPayloadBytes(1); // [1, 2) -> bucket 1
PS::RecordDrawPayloadBytes(2); // [2, 4) -> bucket 2
PS::RecordDrawPayloadBytes(3); // [2, 4) -> bucket 2
PS::RecordDrawPayloadBytes(48); // [32, 64) -> bucket 6
PS::RecordDrawPayloadBytes(64); // [64, 128)-> bucket 7
EXPECT_EQ(PS::TotalPayloadBucket(0), 1u);
EXPECT_EQ(PS::TotalPayloadBucket(1), 1u);
EXPECT_EQ(PS::TotalPayloadBucket(2), 2u);
EXPECT_EQ(PS::TotalPayloadBucket(6), 1u);
EXPECT_EQ(PS::TotalPayloadBucket(7), 1u);
}
// A record far larger than the last bucket must land in the last bucket, not past the
// end of the array.
TEST_F(PipeStatsTest, PayloadHistogramSaturatesInsteadOfOverflowing) {
PS::RecordDrawPayloadBytes(~Uint64{0});
EXPECT_EQ(PS::TotalPayloadBucket(PS::kPayloadHistogramBuckets - 1), 1u);
EXPECT_EQ(PS::TotalPayloadBucket(PS::kPayloadHistogramBuckets), 0u);
}
// The summary line's shape is what an operator greps and what the smoke check in this
// package matches, so it is pinned here rather than left to the log reader's memory.
TEST_F(PipeStatsTest, SummaryLineCarriesEveryClassAndGate) {
PS::AddCalls(PS::CallClass::Draws, 4);
PS::AddCalls(PS::CallClass::AccessorCalls, 50);
PS::AddBytes(PS::ByteClass::StageBuffer, 4096);
PS::OnPresent();
const String line = PS::FormatWindowLine();
EXPECT_NE(line.find("MGPipe stats:"), String::npos) << line;
EXPECT_NE(line.find("draws=4"), String::npos) << line;
// 50 accessor calls over 4 draws, two decimals, no <iomanip>.
EXPECT_NE(line.find("acc/draw=12.50"), String::npos) << line;
EXPECT_NE(line.find("buf=4096.00"), String::npos) << line;
for (Uint32 i = 0; i < static_cast<Uint32>(PS::Gate::Count); ++i) {
EXPECT_NE(line.find("="), String::npos);
}
EXPECT_NE(line.find("gates["), String::npos) << line;
EXPECT_NE(line.find("tex[emit="), String::npos) << line;
}
// Per-frame fields carry two decimals for the same reason acc/draw does: they are small
// and load-bearing (bytes/f sizes SEG_STAGE), and integer division silently rounds a
// whole unit off each of them. 26 draws over 14 frames is 1.86, not 1.
TEST_F(PipeStatsTest, PerFrameFieldsKeepTwoDecimals) {
PS::AddCalls(PS::CallClass::Draws, 26);
PS::AddBytes(PS::ByteClass::StageBuffer, 1360);
for (Uint32 i = 0; i < 14; ++i) {
PS::OnPresent();
}
const String line = PS::FormatWindowLine();
EXPECT_NE(line.find("draws/f=1.86"), String::npos) << line;
EXPECT_NE(line.find("buf=97.14"), String::npos) << line;
}
// Successive summaries report WINDOWS, not run totals: a run total over a workload that
// changes shape (load, then steady state) averages away the very number section 2.3.1
// wants. Advancing the window is an explicit call, not a side effect of formatting.
TEST_F(PipeStatsTest, SummaryLinesReportDisjointWindows) {
PS::AddCalls(PS::CallClass::Draws, 10);
PS::OnPresent();
const String first = PS::FormatWindowLine();
EXPECT_NE(first.find("draws=10"), String::npos) << first;
PS::AdvanceSummaryWindow();
PS::AddCalls(PS::CallClass::Draws, 3);
PS::OnPresent();
const String second = PS::FormatWindowLine();
EXPECT_NE(second.find("draws=3"), String::npos) << second;
EXPECT_NE(second.find("frames=2"), String::npos) << second;
}
// FormatWindowLine is pure. It used to rewrite the window bases as a side effect of
// formatting, so any second reader - a probe, a test, a second reporting channel -
// silently zeroed the next window.
TEST_F(PipeStatsTest, FormattingTwiceDoesNotConsumeTheWindow) {
PS::AddCalls(PS::CallClass::Draws, 7);
PS::OnPresent();
const String first = PS::FormatWindowLine();
const String second = PS::FormatWindowLine();
EXPECT_EQ(first, second) << first << "\n" << second;
EXPECT_NE(second.find("draws=7"), String::npos) << second;
// ...and advancing explicitly does close it.
PS::AdvanceSummaryWindow();
const String third = PS::FormatWindowLine();
EXPECT_NE(third.find("draws=0"), String::npos) << third;
}
TEST_F(PipeStatsTest, SummaryLineSurvivesZeroDraws) {
PS::AddCalls(PS::CallClass::AccessorCalls, 12);
PS::OnPresent();
const String line = PS::FormatWindowLine();
// No draw in the window means there is no per-draw number - and "0.00" beside a
// non-zero acc= would read as one.
EXPECT_NE(line.find("acc/draw=n/a"), String::npos) << line;
EXPECT_NE(line.find("acc=12"), String::npos) << line;
}
// A window with no Present in it has no per-frame reading at all. This used to divide by
// a faked 1 and print the window TOTALS under a "/f" label: a scenario slice that draws
// 47 times and never presents reported 1,404,550 staged bytes as a per-frame figure,
// which is a 47x overstatement of the SEG_STAGE sizing input this package exists to
// produce.
TEST_F(PipeStatsTest, SummaryLineSurvivesZeroFrames) {
PS::AddCalls(PS::CallClass::Draws, 47);
PS::AddBytes(PS::ByteClass::StageBuffer, 1404550);
const String line = PS::FormatWindowLine();
EXPECT_EQ(PS::FrameCount(), 0u);
EXPECT_NE(line.find("window=0"), String::npos) << line;
EXPECT_NE(line.find("draws/f=n/a"), String::npos) << line;
// The bracket is relabelled rather than divided: totals, and marked as totals.
EXPECT_EQ(line.find("bytes/f["), String::npos) << line;
EXPECT_NE(line.find("bytes[buf=1404550"), String::npos) << line;
}
TEST_F(PipeStatsTest, JsonDumpNamesEveryCounter) {
PS::AddBytes(PS::ByteClass::StageUboNamed, 256);
PS::CountGate(PS::Gate::MagmaDynamicTail, /*hit=*/false);
PS::RecordDrawPayloadBytes(9);
PS::OnPresent();
const String json = PS::FormatJson();
for (Uint32 i = 0; i < static_cast<Uint32>(PS::ByteClass::Count); ++i) {
const String name = PS::NameOf(static_cast<PS::ByteClass>(i));
EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json;
}
for (Uint32 i = 0; i < static_cast<Uint32>(PS::CallClass::Count); ++i) {
const String name = PS::NameOf(static_cast<PS::CallClass>(i));
EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json;
}
for (Uint32 i = 0; i < static_cast<Uint32>(PS::Gate::Count); ++i) {
const String name = PS::NameOf(static_cast<PS::Gate>(i));
EXPECT_NE(json.find("\"" + name + "\""), String::npos) << name << " missing from " << json;
}
EXPECT_NE(json.find("\"stage-ubo-named\": 256"), String::npos) << json;
EXPECT_NE(json.find("\"frames\": 1"), String::npos) << json;
EXPECT_NE(json.find("cmd-bytes-per-draw-histogram"), String::npos) << json;
}
// The counter names are the TracyPlot series names and the JSON keys; a rename is a
// breaking change for every recorded baseline, so the whole set is pinned.
TEST_F(PipeStatsTest, CounterNamesAreStable) {
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageBuffer), "stage-buffer");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageTexture), "stage-texture");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageUboGlobal), "stage-ubo-global");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageUboNamed), "stage-ubo-named");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageVertexClient), "stage-vertex-client");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndexClient), "stage-index-client");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::StageIndirectCmd), "stage-indirect-cmd");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::PersistentMapPush), "persistent-map-push");
EXPECT_STREQ(PS::NameOf(PS::ByteClass::ResidualValueBlock), "residual-value-block");
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytRenderState), "espryt-render-state");
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytTextureSyncList), "espryt-texture-sync-list");
EXPECT_STREQ(PS::NameOf(PS::Gate::EsprytUnitBindingsEpoch), "espryt-unit-bindings-epoch");
EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaDrawFastPath), "magma-draw-fastpath");
EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaPipelineMemo), "magma-pipeline-memo");
EXPECT_STREQ(PS::NameOf(PS::Gate::MagmaDynamicTail), "magma-dynamic-tail");
}
// A summary is emitted every kSummaryFramePeriod presents. The period is a constant the
// smoke check depends on, so a change to it has to break a test.
TEST_F(PipeStatsTest, SummaryPeriodIsOneHundredAndTwentyFrames) {
EXPECT_EQ(PS::SummaryFramePeriod(), 120u);
for (Uint64 i = 0; i < PS::SummaryFramePeriod(); ++i) {
PS::OnPresent();
}
EXPECT_EQ(PS::FrameCount(), PS::SummaryFramePeriod());
}
} // namespace
+36
View File
@@ -0,0 +1,36 @@
cmake_minimum_required(VERSION 3.14)
# The MG_Remote wire layer: framing, the SPSC ring, the in-process transport,
# SCM_RIGHTS descriptor passing and the generated control-plane schema. Only
# reachable with MOBILEGL_BUILD_DISAGGREGATED=ON (see MG_Test/CMakeLists.txt).
set(MOBILEGL_WIRE_TESTS
FramingTest
RingTest
InProcessTransportTest
ProtocolSmokeTest
)
if (NOT WIN32)
# SCM_RIGHTS and fork(): POSIX only.
list(APPEND MOBILEGL_WIRE_TESTS FdPassingTest)
endif()
include(GoogleTest)
foreach (test IN LISTS MOBILEGL_WIRE_TESTS)
add_executable(${test} ${test}.cpp)
target_include_directories(${test} PRIVATE
${MGL_ROOT}/include
${MGL_ROOT}/MobileGL
${MGL_ROOT}/3rdparty/flatbuffers/include
)
target_link_libraries(${test} PRIVATE
GTest::gtest_main
${LINK_LIBRARIES}
)
gtest_discover_tests(${test} DISCOVERY_TIMEOUT 30 PROPERTIES LABELS unit)
endforeach ()
+305
View File
@@ -0,0 +1,305 @@
// MobileGL - MobileGL/MG_Test/Wire/FdPassingTest.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
// SCM_RIGHTS across a real process boundary: a forked child creates a shared
// segment, fills it, and hands the descriptor over the aux socket; the parent
// adopts it, maps it read-only and compares every byte.
//
// This is the test the earlier branch never had. Its transport hardcoded
// `out->fd = -1` in the offer poll, so its data plane could not move a byte
// between processes - and nothing in its suite noticed, because everything ran
// in one process.
#include <MG_Remote/Transport/Doorbell.h>
#include <MG_Remote/Transport/FdPassing.h>
#include <MG_Remote/Transport/ShmSegment.h>
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <string>
#include <sys/socket.h>
#include <sys/wait.h>
#include <thread>
#include <unistd.h>
#include <vector>
using namespace MobileGL::MG_Remote::Transport;
namespace {
constexpr std::uint64_t kSegmentSize = 64 * 1024;
std::uint8_t ByteAt(std::uint64_t index) {
return static_cast<std::uint8_t>((index * 31u + 7u) & 0xFFu);
}
// Child-side exit codes, so a failure says where it happened.
enum ChildStatus : int {
kChildOk = 0,
kChildCreateFailed = 2,
kChildMapFailed = 3,
kChildSendFailed = 4,
};
} // namespace
TEST(FdPassingTest, IsSupportedOnThisPlatform) { EXPECT_TRUE(FdPassing::Supported()); }
TEST(FdPassingTest, ChildSharesASegmentThatTheParentMapsAndVerifies) {
int sockets[2] = {-1, -1};
ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK);
const std::string sideband = "SegmentRef{id=7,kind=Stage}";
const pid_t pid = ::fork();
ASSERT_GE(pid, 0);
if (pid == 0) {
// Child. No gtest assertions here: a failed expectation in a forked
// child would report into a copy of the parent's test state.
::close(sockets[0]);
int status = kChildOk;
ShmSegment segment;
if (ShmSegment::Create("fdpass", kSegmentSize, segment) != MOBILEGL_OK) {
status = kChildCreateFailed;
} else if (segment.Map(false) != MOBILEGL_OK) {
status = kChildMapFailed;
} else {
auto* bytes = static_cast<std::uint8_t*>(segment.Data());
for (std::uint64_t i = 0; i < kSegmentSize; ++i) {
bytes[i] = ByteAt(i);
}
const MobileGLByteSpan span{sideband.data(), sideband.size()};
if (FdPassing::SendFd(sockets[1], segment.Fd(), span) != MOBILEGL_OK) {
status = kChildSendFailed;
}
}
::close(sockets[1]);
::_exit(status);
}
// Parent.
::close(sockets[1]);
// A destination smaller than kMaxSidebandBytes is refused BEFORE the
// datagram is consumed, so the descriptor is not lost by a caller that
// guessed the size wrong.
std::vector<std::uint8_t> small(8);
int fd = -1;
std::uint64_t required = 0;
MobileGLMutableByteSpan smallSpan{small.data(), small.size()};
EXPECT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, smallSpan, &required, 5000),
MOBILEGL_ERR_BUFFER_TOO_SMALL);
EXPECT_EQ(required, FdPassing::kMaxSidebandBytes);
EXPECT_EQ(fd, -1);
std::vector<std::uint8_t> sidebandBuffer(FdPassing::kMaxSidebandBytes);
std::uint64_t sidebandSize = 0;
MobileGLMutableByteSpan sidebandSpan{sidebandBuffer.data(), sidebandBuffer.size()};
ASSERT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, sidebandSpan, &sidebandSize, 5000),
MOBILEGL_OK);
ASSERT_GE(fd, 0);
EXPECT_EQ(std::string(reinterpret_cast<const char*>(sidebandBuffer.data()),
static_cast<std::size_t>(sidebandSize)),
sideband);
ShmSegment adopted;
ASSERT_EQ(ShmSegment::Adopt(fd, kSegmentSize, adopted), MOBILEGL_OK);
EXPECT_TRUE(adopted.Valid());
ASSERT_EQ(adopted.Map(true), MOBILEGL_OK);
EXPECT_TRUE(adopted.MappedReadOnly());
const auto* bytes = static_cast<const std::uint8_t*>(adopted.Data());
ASSERT_NE(bytes, nullptr);
std::uint64_t mismatches = 0;
for (std::uint64_t i = 0; i < kSegmentSize; ++i) {
if (bytes[i] != ByteAt(i)) {
++mismatches;
}
}
EXPECT_EQ(mismatches, 0u);
int childStatus = 0;
ASSERT_EQ(::waitpid(pid, &childStatus, 0), pid);
ASSERT_TRUE(WIFEXITED(childStatus));
EXPECT_EQ(WEXITSTATUS(childStatus), kChildOk);
::close(sockets[0]);
}
TEST(FdPassingTest, ReceiveTimesOutWithNoOffer) {
int sockets[2] = {-1, -1};
ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK);
std::vector<std::uint8_t> sidebandBuffer(FdPassing::kMaxSidebandBytes);
int fd = -1;
std::uint64_t sidebandSize = 0;
MobileGLMutableByteSpan span{sidebandBuffer.data(), sidebandBuffer.size()};
EXPECT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, span, &sidebandSize, 20), MOBILEGL_ERR_TIMEOUT);
EXPECT_EQ(fd, -1);
::close(sockets[0]);
::close(sockets[1]);
}
TEST(FdPassingTest, RejectsBadArguments) {
int sockets[2] = {-1, -1};
ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK);
const std::vector<std::uint8_t> tooBig(FdPassing::kMaxSidebandBytes + 1, 0);
const MobileGLByteSpan oversized{tooBig.data(), tooBig.size()};
EXPECT_EQ(FdPassing::SendFd(sockets[1], sockets[0], oversized), MOBILEGL_ERR_INVALID_ARGUMENT);
EXPECT_EQ(FdPassing::SendFd(sockets[1], -1, MobileGLByteSpan{nullptr, 0}),
MOBILEGL_ERR_INVALID_ARGUMENT);
::close(sockets[0]);
::close(sockets[1]);
}
TEST(FdPassingTest, SegmentWithoutASidebandStillCarriesItsDescriptor) {
int sockets[2] = {-1, -1};
ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK);
ShmSegment segment;
ASSERT_EQ(ShmSegment::Create("nosideband", 4096, segment), MOBILEGL_OK);
ASSERT_EQ(segment.Map(false), MOBILEGL_OK);
static_cast<std::uint8_t*>(segment.Data())[0] = 0xA5;
ASSERT_EQ(FdPassing::SendFd(sockets[1], segment.Fd(), MobileGLByteSpan{nullptr, 0}),
MOBILEGL_OK);
std::vector<std::uint8_t> sidebandBuffer(FdPassing::kMaxSidebandBytes);
int fd = -1;
std::uint64_t sidebandSize = 123;
MobileGLMutableByteSpan span{sidebandBuffer.data(), sidebandBuffer.size()};
ASSERT_EQ(FdPassing::ReceiveFd(sockets[0], &fd, span, &sidebandSize, 5000), MOBILEGL_OK);
EXPECT_EQ(sidebandSize, 0u);
ASSERT_GE(fd, 0);
ShmSegment adopted;
ASSERT_EQ(ShmSegment::Adopt(fd, 4096, adopted), MOBILEGL_OK);
ASSERT_EQ(adopted.Map(true), MOBILEGL_OK);
EXPECT_EQ(static_cast<const std::uint8_t*>(adopted.Data())[0], 0xA5);
::close(sockets[0]);
::close(sockets[1]);
}
// The `spawn` doorbell rides the same kind of socket as the fd channel, so it
// is covered here rather than beside the in-process one.
TEST(FdPassingTest, SocketDoorbellWakesAParkedWaiter) {
int sockets[2] = {-1, -1};
ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK);
// One end each: the waiter reads its own end, the notifier writes the
// other, exactly as the two processes will.
SocketDoorbell waiterBell(sockets[0], kDoorbellWatermarkAdvanced, /*ownsFd=*/false);
SocketDoorbell notifierBell(sockets[1], kDoorbellWatermarkAdvanced, /*ownsFd=*/false);
std::atomic<std::uint32_t> parked{0};
std::atomic<bool> ready{false};
std::atomic<bool> woke{false};
std::thread waiter([&] {
woke.store(waiterBell.Wait(
parked, [&] { return ready.load(std::memory_order_acquire); }, kDefaultSpinUs, 5000));
});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ready.store(true, std::memory_order_release);
NotifyIfParked(notifierBell, parked);
waiter.join();
EXPECT_TRUE(woke.load());
EXPECT_EQ(parked.load(), 0u);
::close(sockets[0]);
::close(sockets[1]);
}
TEST(FdPassingTest, SocketDoorbellTimesOutAndRemembersAnEarlyWakeup) {
int sockets[2] = {-1, -1};
ASSERT_EQ(FdPassing::CreateSocketPair(sockets), MOBILEGL_OK);
SocketDoorbell waiterBell(sockets[0], kDoorbellRingAdvanced, /*ownsFd=*/false);
SocketDoorbell notifierBell(sockets[1], kDoorbellRingAdvanced, /*ownsFd=*/false);
// Nothing rings: the park has to end on its deadline, not hang.
const auto start = std::chrono::steady_clock::now();
EXPECT_FALSE(waiterBell.Park(30));
EXPECT_GE(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count(),
20);
// A wakeup that arrives before anyone parks is not lost - it is sitting in
// the socket buffer, so the next Park returns at once.
notifierBell.Notify();
EXPECT_TRUE(waiterBell.Park(1000));
// ...and it was consumed, so the one after that times out again.
EXPECT_FALSE(waiterBell.Park(10));
::close(sockets[0]);
::close(sockets[1]);
}
// A doorbell whose peer has hung up must report that, not keep saying "ready".
// Park used to treat any `poll` return > 0 as a wakeup without ever looking at
// revents, and a closed peer leaves a stream socket permanently poll-ready
// with nothing to read - so Doorbell::Wait re-parked in a tight loop at full
// clock, unbounded when the caller passed kWaitForever. That is the pathology
// the bidirectional doorbell exists to prevent, arrived at from the other
// side.
TEST(FdPassingTest, SocketDoorbellStopsParkingWhenThePeerHangsUp) {
// A SOCK_STREAM pair, not FdPassing::CreateSocketPair's datagram pair:
// measured on Linux, a closed peer makes a stream end report
// POLLIN|POLLHUP with recv()==0, while a datagram end reports no readiness
// at all. The stream shape is what the spawn transport will use, and it is
// the shape that used to spin.
int sockets[2] = {-1, -1};
ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets), 0);
SocketDoorbell waiterBell(sockets[0], kDoorbellRingAdvanced, /*ownsFd=*/true);
ASSERT_EQ(::close(sockets[1]), 0);
const auto start = std::chrono::steady_clock::now();
EXPECT_FALSE(waiterBell.Park(kWaitForever));
EXPECT_TRUE(waiterBell.Dead());
// Latched: no second syscall storm either.
EXPECT_FALSE(waiterBell.Park(kWaitForever));
// ...and a Wait with no deadline at all gives up instead of re-parking.
std::atomic<std::uint32_t> parked{0};
EXPECT_FALSE(waiterBell.Wait(
parked, [] { return false; }, /*spinUs=*/0, kWaitForever));
EXPECT_EQ(parked.load(), 0u);
EXPECT_LT(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count(),
1000);
}
TEST(FdPassingTest, SocketDoorbellStillDeliversTheLastRingBeforeAHangup) {
int sockets[2] = {-1, -1};
ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets), 0);
SocketDoorbell waiterBell(sockets[0], kDoorbellRingAdvanced, /*ownsFd=*/true);
SocketDoorbell notifierBell(sockets[1], kDoorbellRingAdvanced, /*ownsFd=*/false);
// Ring, then die. Detecting the hangup must not swallow the wakeup that
// was already queued - the peer's last publish is the one a waiter is
// most likely to be blocked on.
notifierBell.Notify();
ASSERT_EQ(::close(sockets[1]), 0);
EXPECT_TRUE(waiterBell.Park(1000));
EXPECT_TRUE(waiterBell.Dead());
EXPECT_FALSE(waiterBell.Park(kWaitForever));
}
+197
View File
@@ -0,0 +1,197 @@
// MobileGL - MobileGL/MG_Test/Wire/FramingTest.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 control-channel frame codec, and specifically the two contracts the
// earlier branch's codec got wrong: a bad header must be REPORTED (it used to
// turn into a silent permanent hang) and a too-small destination buffer must
// KEEP the message (it used to fail the call and drop it, wedging the stream).
#include <MG_Remote/Transport/Framing.h>
#include <gtest/gtest.h>
#include <cstring>
#include <numeric>
#include <vector>
using namespace MobileGL::MG_Remote::Transport;
namespace {
std::vector<std::uint8_t> Pattern(std::size_t size, std::uint8_t seed) {
std::vector<std::uint8_t> out(size);
for (std::size_t i = 0; i < size; ++i) {
out[i] = static_cast<std::uint8_t>(seed + i * 7u);
}
return out;
}
} // namespace
TEST(FramingTest, RoundTripsTwoMessagesFedOneByteAtATime) {
const std::vector<std::uint8_t> first = Pattern(37, 0x11);
const std::vector<std::uint8_t> second = Pattern(120, 0x83);
std::vector<std::uint8_t> stream;
ASSERT_EQ(AppendFrame(stream, first.data(), first.size()), MOBILEGL_OK);
ASSERT_EQ(AppendFrame(stream, second.data(), second.size()), MOBILEGL_OK);
EXPECT_EQ(stream.size(), 2 * kFrameHeaderSize + first.size() + second.size());
// A stream transport hands over arbitrary fragments; one byte at a time is
// the worst case and must work.
FrameReader reader;
std::vector<std::vector<std::uint8_t>> received;
for (std::uint8_t byte : stream) {
ASSERT_EQ(reader.Feed(&byte, 1), MOBILEGL_OK);
while (reader.HasMessage()) {
std::vector<std::uint8_t> message;
ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK);
received.push_back(std::move(message));
}
}
ASSERT_EQ(received.size(), 2u);
EXPECT_EQ(received[0], first);
EXPECT_EQ(received[1], second);
EXPECT_FALSE(reader.Failed());
EXPECT_EQ(reader.BufferedBytes(), 0u);
}
TEST(FramingTest, MagicIsOnTheWireAsMGLF) {
std::vector<std::uint8_t> stream;
const std::uint8_t payload = 0xAB;
ASSERT_EQ(AppendFrame(stream, &payload, 1), MOBILEGL_OK);
ASSERT_GE(stream.size(), 4u);
EXPECT_EQ(stream[0], 'M');
EXPECT_EQ(stream[1], 'G');
EXPECT_EQ(stream[2], 'L');
EXPECT_EQ(stream[3], 'F');
}
TEST(FramingTest, EmptyPayloadRoundTrips) {
std::vector<std::uint8_t> stream;
ASSERT_EQ(AppendFrame(stream, nullptr, 0), MOBILEGL_OK);
FrameReader reader;
ASSERT_EQ(reader.Feed(stream.data(), stream.size()), MOBILEGL_OK);
ASSERT_TRUE(reader.HasMessage());
EXPECT_EQ(reader.PendingMessageSize(), 0u);
std::vector<std::uint8_t> message{0xFF};
ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK);
EXPECT_TRUE(message.empty());
}
TEST(FramingTest, BadMagicIsReportedAndLatchesTheReaderDead) {
std::uint8_t header[8] = {};
const std::uint32_t wrongMagic = 0xDEADBEEF;
const std::uint32_t length = 4;
std::memcpy(header + 0, &wrongMagic, sizeof(wrongMagic));
std::memcpy(header + 4, &length, sizeof(length));
FrameReader reader;
// The failure surfaces at Feed time, not as a message that never arrives.
EXPECT_EQ(reader.Feed(header, sizeof(header)), MOBILEGL_ERR_PROTOCOL_MISMATCH);
EXPECT_TRUE(reader.Failed());
EXPECT_FALSE(reader.HasMessage());
// And it stays dead: a desynchronized stream is never re-synchronized by
// feeding it more bytes.
const std::uint8_t more[4] = {1, 2, 3, 4};
EXPECT_EQ(reader.Feed(more, sizeof(more)), MOBILEGL_ERR_PROTOCOL_MISMATCH);
std::vector<std::uint8_t> message;
EXPECT_EQ(reader.TakeMessage(message), MOBILEGL_ERR_PROTOCOL_MISMATCH);
}
TEST(FramingTest, OversizedLengthIsRejectedBeforeAnyAllocation) {
std::uint8_t header[8] = {};
const std::uint32_t magic = kFrameMagic;
const std::uint32_t length = static_cast<std::uint32_t>(kMaxFramePayloadSize) + 1;
std::memcpy(header + 0, &magic, sizeof(magic));
std::memcpy(header + 4, &length, sizeof(length));
FrameReader reader;
EXPECT_EQ(reader.Feed(header, sizeof(header)), MOBILEGL_ERR_PROTOCOL_MISMATCH);
EXPECT_TRUE(reader.Failed());
}
TEST(FramingTest, SendRefusesAPayloadOverTheCap) {
std::vector<std::uint8_t> stream;
const std::uint8_t dummy = 0;
// The size check happens before the payload is touched, so no 64MiB
// allocation is needed to cover it.
EXPECT_EQ(AppendFrame(stream, &dummy, kMaxFramePayloadSize + 1), MOBILEGL_ERR_INVALID_ARGUMENT);
EXPECT_TRUE(stream.empty());
}
TEST(FramingTest, BufferTooSmallReportsTheSizeAndKeepsTheMessage) {
const std::vector<std::uint8_t> payload = Pattern(200, 0x5A);
std::vector<std::uint8_t> stream;
ASSERT_EQ(AppendFrame(stream, payload.data(), payload.size()), MOBILEGL_OK);
FrameReader reader;
ASSERT_EQ(reader.Feed(stream.data(), stream.size()), MOBILEGL_OK);
ASSERT_TRUE(reader.HasMessage());
std::vector<std::uint8_t> small(8);
std::uint64_t required = 0;
MobileGLMutableByteSpan smallSpan{small.data(), small.size()};
EXPECT_EQ(reader.TakeMessage(smallSpan, &required), MOBILEGL_ERR_BUFFER_TOO_SMALL);
EXPECT_EQ(required, payload.size());
// Still there. This is the whole point: the old transport dropped it here
// and the stream never recovered.
ASSERT_TRUE(reader.HasMessage());
std::vector<std::uint8_t> big(required);
std::uint64_t got = 0;
MobileGLMutableByteSpan bigSpan{big.data(), big.size()};
ASSERT_EQ(reader.TakeMessage(bigSpan, &got), MOBILEGL_OK);
EXPECT_EQ(got, payload.size());
EXPECT_EQ(big, payload);
EXPECT_FALSE(reader.HasMessage());
}
TEST(FramingTest, TakeWithNoCompleteMessageDoesNotBlockOrCorrupt) {
const std::vector<std::uint8_t> payload = Pattern(64, 0x22);
std::vector<std::uint8_t> stream;
ASSERT_EQ(AppendFrame(stream, payload.data(), payload.size()), MOBILEGL_OK);
FrameReader reader;
// Header plus half the payload.
ASSERT_EQ(reader.Feed(stream.data(), kFrameHeaderSize + 32), MOBILEGL_OK);
EXPECT_FALSE(reader.HasMessage());
EXPECT_EQ(reader.PendingMessageSize(), 0u);
std::vector<std::uint8_t> message;
EXPECT_EQ(reader.TakeMessage(message), MOBILEGL_ERR_TIMEOUT);
ASSERT_EQ(reader.Feed(stream.data() + kFrameHeaderSize + 32,
stream.size() - kFrameHeaderSize - 32),
MOBILEGL_OK);
ASSERT_TRUE(reader.HasMessage());
ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK);
EXPECT_EQ(message, payload);
}
TEST(FramingTest, ManyMessagesCompactTheBufferInsteadOfGrowing) {
// Drives the reader past its compaction threshold so the "consumed bytes
// are reclaimed" path is actually taken.
const std::vector<std::uint8_t> payload = Pattern(1024, 0x07);
FrameReader reader;
for (int i = 0; i < 300; ++i) {
std::vector<std::uint8_t> stream;
ASSERT_EQ(AppendFrame(stream, payload.data(), payload.size()), MOBILEGL_OK);
ASSERT_EQ(reader.Feed(stream.data(), stream.size()), MOBILEGL_OK);
ASSERT_TRUE(reader.HasMessage());
std::vector<std::uint8_t> message;
ASSERT_EQ(reader.TakeMessage(message), MOBILEGL_OK);
ASSERT_EQ(message, payload);
}
EXPECT_EQ(reader.BufferedBytes(), 0u);
}
@@ -0,0 +1,398 @@
// MobileGL - MobileGL/MG_Test/Wire/InProcessTransportTest.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 `inproc` transport: message queues in both directions, the
// buffer-too-small contract, shutdown semantics, descriptor hand-off, and the
// condvar doorbells the rings park on.
#include <MG_Remote/Transport/FdPassing.h>
#include <MG_Remote/Transport/InProcessTransport.h>
#include <gtest/gtest.h>
#include <atomic>
#include <chrono>
#include <cstring>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#if !defined(_WIN32)
#include <unistd.h>
#endif
using namespace MobileGL::MG_Remote::Transport;
namespace {
MobileGLByteSpan Span(const std::string& text) {
return MobileGLByteSpan{text.data(), text.size()};
}
std::string Receive(ITransport& transport, std::uint32_t timeoutMs = 1000) {
std::vector<std::uint8_t> buffer(4096);
std::uint64_t size = 0;
MobileGLMutableByteSpan span{buffer.data(), buffer.size()};
const MobileGLResult result = transport.ReceiveFrame(span, &size, timeoutMs);
if (result != MOBILEGL_OK) {
return std::string("<result=") + std::to_string(static_cast<int>(result)) + ">";
}
return std::string(reinterpret_cast<const char*>(buffer.data()),
static_cast<std::size_t>(size));
}
} // namespace
TEST(InProcessTransportTest, CarriesFramesInBothDirections) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
ASSERT_TRUE(client && server);
EXPECT_EQ(client->Role(), TransportRole::InProcess);
const std::string hello = "Hello{abiMajor=1}";
const std::string welcome = "Welcome{serverPid=42}";
ASSERT_EQ(client->SendFrame(Span(hello)), MOBILEGL_OK);
EXPECT_EQ(server->PeekFrameSize(), hello.size());
// A message goes to the PEER's inbox, never back to the sender.
EXPECT_EQ(client->PeekFrameSize(), 0u);
EXPECT_EQ(Receive(*server), hello);
ASSERT_EQ(server->SendFrame(Span(welcome)), MOBILEGL_OK);
EXPECT_EQ(Receive(*client), welcome);
}
TEST(InProcessTransportTest, PreservesOrder) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
for (int i = 0; i < 64; ++i) {
const std::string message = "msg-" + std::to_string(i);
ASSERT_EQ(client->SendFrame(Span(message)), MOBILEGL_OK);
}
for (int i = 0; i < 64; ++i) {
EXPECT_EQ(Receive(*server), "msg-" + std::to_string(i));
}
}
TEST(InProcessTransportTest, BufferTooSmallKeepsTheMessage) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
const std::string message(300, 'x');
ASSERT_EQ(client->SendFrame(Span(message)), MOBILEGL_OK);
std::vector<std::uint8_t> small(16);
std::uint64_t required = 0;
MobileGLMutableByteSpan smallSpan{small.data(), small.size()};
EXPECT_EQ(server->ReceiveFrame(smallSpan, &required, 0), MOBILEGL_ERR_BUFFER_TOO_SMALL);
EXPECT_EQ(required, message.size());
// Still queued - the caller just retries with the size it was told.
EXPECT_EQ(server->PeekFrameSize(), message.size());
EXPECT_EQ(Receive(*server), message);
}
TEST(InProcessTransportTest, PollAndTimeoutDoNotBlockForever) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
std::vector<std::uint8_t> buffer(64);
std::uint64_t size = 0;
MobileGLMutableByteSpan span{buffer.data(), buffer.size()};
EXPECT_EQ(server->ReceiveFrame(span, &size, 0), MOBILEGL_ERR_TIMEOUT);
const auto start = std::chrono::steady_clock::now();
EXPECT_EQ(server->ReceiveFrame(span, &size, 30), MOBILEGL_ERR_TIMEOUT);
EXPECT_GE(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count(),
20);
}
TEST(InProcessTransportTest, ShutdownDrainsBeforeItCloses) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
const std::string last = "Fatal{code=DeviceLost}";
ASSERT_EQ(client->SendFrame(Span(last)), MOBILEGL_OK);
client->Shutdown();
// A peer that shuts down right after sending must not lose its last
// message - that is usually the one that says why it is going away.
EXPECT_EQ(Receive(*server), last);
std::vector<std::uint8_t> buffer(64);
std::uint64_t size = 0;
MobileGLMutableByteSpan span{buffer.data(), buffer.size()};
EXPECT_EQ(server->ReceiveFrame(span, &size, 100), MOBILEGL_ERR_TRANSPORT_CLOSED);
EXPECT_EQ(server->SendFrame(Span(last)), MOBILEGL_ERR_TRANSPORT_CLOSED);
}
TEST(InProcessTransportTest, BlockedReceiverWakesOnSendAndOnShutdown) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
std::atomic<bool> got{false};
std::thread reader([&] {
got.store(Receive(*server, kWaitForever) == "wake");
});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ASSERT_EQ(client->SendFrame(Span(std::string("wake"))), MOBILEGL_OK);
reader.join();
EXPECT_TRUE(got.load());
std::thread closer([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(20));
client->Shutdown();
});
std::vector<std::uint8_t> buffer(64);
std::uint64_t size = 0;
MobileGLMutableByteSpan span{buffer.data(), buffer.size()};
EXPECT_EQ(server->ReceiveFrame(span, &size, kWaitForever), MOBILEGL_ERR_TRANSPORT_CLOSED);
closer.join();
}
TEST(InProcessTransportTest, RefusesAPayloadOverTheFrameCap) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
// Not allocated: the cap is checked before the bytes are touched. Keeping
// the same limit as the socket transports means nothing passes CI here and
// then fails after the switch to `spawn`.
const std::uint8_t dummy = 0;
MobileGLByteSpan huge{&dummy, 64ull * 1024 * 1024 + 1};
EXPECT_EQ(client->SendFrame(huge), MOBILEGL_ERR_INVALID_ARGUMENT);
}
#if !defined(_WIN32)
TEST(InProcessTransportTest, HandsOverADescriptorAndItsSideband) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
int pipeFds[2] = {-1, -1};
ASSERT_EQ(::pipe(pipeFds), 0);
const std::string sideband = "SegmentRef{id=1,kind=Cmd}";
ASSERT_EQ(client->ShareFd(pipeFds[0], Span(sideband)), MOBILEGL_OK);
// Symmetric with the SCM_RIGHTS path: a short sideband buffer is refused
// before anything is consumed, so the descriptor is never dropped.
std::vector<std::uint8_t> small(8);
int fd = -1;
std::uint64_t required = 0;
MobileGLMutableByteSpan smallSpan{small.data(), small.size()};
EXPECT_EQ(server->ReceiveFd(&fd, smallSpan, &required, 0), MOBILEGL_ERR_BUFFER_TOO_SMALL);
EXPECT_EQ(required, FdPassing::kMaxSidebandBytes);
EXPECT_EQ(fd, -1);
std::vector<std::uint8_t> big(FdPassing::kMaxSidebandBytes);
std::uint64_t sidebandSize = 0;
MobileGLMutableByteSpan bigSpan{big.data(), big.size()};
ASSERT_EQ(server->ReceiveFd(&fd, bigSpan, &sidebandSize, 100), MOBILEGL_OK);
ASSERT_GE(fd, 0);
EXPECT_EQ(std::string(reinterpret_cast<const char*>(big.data()),
static_cast<std::size_t>(sidebandSize)),
sideband);
// Same open file description, independent descriptor.
const char payload[] = "bytes";
ASSERT_EQ(::write(pipeFds[1], payload, sizeof(payload)), static_cast<ssize_t>(sizeof(payload)));
char readBack[sizeof(payload)] = {};
ASSERT_EQ(::read(fd, readBack, sizeof(readBack)), static_cast<ssize_t>(sizeof(payload)));
EXPECT_STREQ(readBack, payload);
::close(fd);
::close(pipeFds[0]);
::close(pipeFds[1]);
}
TEST(InProcessTransportTest, AFrameWakeupIsNotEatenByAWaiterOnDescriptors) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
// Two readers on the SAME endpoint, blocked on two different predicates.
// With one condition_variable per direction and notify_one, the SendFrame
// below could be delivered to the descriptor waiter, which re-tests its
// own predicate and goes back to sleep - and the message then sits
// undelivered until some unrelated later event. ITransport narrows the
// contract to one dedicated reader thread, but that is a comment, and the
// first caller that splits its reader should not have to discover this.
std::atomic<bool> fdWaiterStarted{false};
std::thread fdWaiter([&] {
std::vector<std::uint8_t> sideband(FdPassing::kMaxSidebandBytes);
MobileGLMutableByteSpan span{sideband.data(), sideband.size()};
int fd = -1;
std::uint64_t size = 0;
fdWaiterStarted.store(true);
// Never offered a descriptor: this one ends on the Shutdown below.
EXPECT_EQ(client->ReceiveFd(&fd, span, &size, kWaitForever),
MOBILEGL_ERR_TRANSPORT_CLOSED);
EXPECT_EQ(fd, -1);
});
while (!fdWaiterStarted.load()) {
std::this_thread::yield();
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::atomic<bool> frameWaiterStarted{false};
std::string got;
std::thread frameWaiter([&] {
frameWaiterStarted.store(true);
got = Receive(*client, 4000);
});
while (!frameWaiterStarted.load()) {
std::this_thread::yield();
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
const std::string message = "wake the right waiter";
const auto start = std::chrono::steady_clock::now();
ASSERT_EQ(server->SendFrame(Span(message)), MOBILEGL_OK);
frameWaiter.join();
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count();
EXPECT_EQ(got, message);
// Not "eventually, when the receive timed out and re-checked".
EXPECT_LT(elapsedMs, 2000);
client->Shutdown();
fdWaiter.join();
}
#endif
TEST(InProcessTransportTest, DoorbellWakesAParkedWaiter) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
// producerParked / consumerParked live in RingControl; here a standalone
// flag stands in for one.
std::atomic<std::uint32_t> parked{0};
std::atomic<bool> ready{false};
std::atomic<bool> woke{false};
std::thread waiter([&] {
woke.store(client->SelfDoorbell().Wait(
parked, [&] { return ready.load(std::memory_order_acquire); }, kDefaultSpinUs, 5000));
});
std::this_thread::sleep_for(std::chrono::milliseconds(20));
ready.store(true, std::memory_order_release);
// The peer only rings when the waiter says it parked, which is what makes
// the common (spin-only) case free.
NotifyIfParked(server->PeerDoorbell(), parked);
waiter.join();
EXPECT_TRUE(woke.load());
EXPECT_EQ(parked.load(), 0u);
}
TEST(InProcessTransportTest, DoorbellReturnsImmediatelyWhenAlreadyReady) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
std::atomic<std::uint32_t> parked{0};
// No notification is sent at all: a condition that is already true must
// never park, or the lost-wakeup window would be reachable.
EXPECT_TRUE(client->SelfDoorbell().Wait(
parked, [] { return true; }, kDefaultSpinUs, 0));
EXPECT_EQ(parked.load(), 0u);
}
TEST(InProcessTransportTest, DoorbellTimesOutWhenNothingHappens) {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
InProcessTransport::CreatePair(client, server);
std::atomic<std::uint32_t> parked{0};
const auto start = std::chrono::steady_clock::now();
EXPECT_FALSE(client->SelfDoorbell().Wait(
parked, [] { return false; }, kDefaultSpinUs, 30));
EXPECT_GE(std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start)
.count(),
20);
EXPECT_EQ(parked.load(), 0u);
}
// The design's own steady state: the consumer spun, set consumerParked and blocked
// with NO deadline. Shutdown has to bring that thread back, and a single Notify
// cannot - Doorbell::Wait consumes it, re-tests a condition that is still false,
// and with kWaitForever parks again. Only a bell that reports Dead() ends the
// loop, which is what InProcessChannel::Close rings now.
//
// A regression here is a HANG, so the join is bounded: the waiter owns its state
// through a shared_ptr and is detached on timeout, and the test fails red after
// five seconds instead of wedging the CI job.
TEST(InProcessTransportTest, ShutdownUnparksAWaiterWithNoDeadline) {
struct Shared {
std::unique_ptr<InProcessTransport> client;
std::unique_ptr<InProcessTransport> server;
std::atomic<std::uint32_t> parked{0};
std::atomic<bool> returned{false};
std::atomic<bool> woke{true};
};
auto shared = std::make_shared<Shared>();
InProcessTransport::CreatePair(shared->client, shared->server);
std::thread waiter([shared] {
shared->woke.store(shared->server->SelfDoorbell().Wait(
shared->parked, [] { return false; }, kDefaultSpinUs, kWaitForever));
shared->returned.store(true, std::memory_order_release);
});
// Past the spin and announced as parked; a little longer and it is inside
// Park. (A Kill that lands before the Park is handled too - Park returns at
// once on a dead bell - but the case under test is the parked one.)
while (shared->parked.load() == 0) {
std::this_thread::yield();
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
ASSERT_FALSE(shared->returned.load());
shared->client->Shutdown();
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (!shared->returned.load(std::memory_order_acquire) &&
std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
if (!shared->returned.load(std::memory_order_acquire)) {
waiter.detach();
FAIL() << "Shutdown did not unpark a waiter with no deadline within 5 s: the inproc doorbell "
"has no death state, so the waiter consumed the ring and parked again";
}
waiter.join();
// No wakeup was consumed - the bell died - and the park flag is clear.
EXPECT_FALSE(shared->woke.load());
EXPECT_TRUE(shared->server->SelfDoorbell().Dead());
EXPECT_TRUE(shared->client->SelfDoorbell().Dead());
EXPECT_EQ(shared->parked.load(), 0u);
// Sticky: a wait with no deadline that ARRIVES after the Shutdown returns at
// once rather than parking, so a late thread cannot hang either.
const auto start = std::chrono::steady_clock::now();
EXPECT_FALSE(shared->server->SelfDoorbell().Wait(
shared->parked, [] { return false; }, 0, kWaitForever));
EXPECT_LT(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start)
.count(),
1000);
EXPECT_EQ(shared->parked.load(), 0u);
}
+149
View File
@@ -0,0 +1,149 @@
// MobileGL - MobileGL/MG_Test/Wire/ProtocolSmokeTest.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 committed control-plane schema: encode/decode a handshake through the
// generated header, and pin the union tag values, which are wire numbers that
// may only ever be appended to.
#include <MG_Remote/Protocol/generated/protocol_generated.h>
#include <MG_Remote/Transport/Framing.h>
#include <MG_Remote/Transport/InProcessTransport.h>
#include <gtest/gtest.h>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
using namespace MobileGL::Wire;
namespace Transport = MobileGL::MG_Remote::Transport;
namespace {
std::vector<std::uint8_t> BuildHello() {
::flatbuffers::FlatBufferBuilder builder(1024);
const std::vector<std::uint8_t> config{1, 2, 3, 4};
auto hello = CreateHelloDirect(builder, MOBILEGL_PROTOCOL_ABI_MAJOR,
MOBILEGL_PROTOCOL_ABI_MINOR, "mobilegl-test-build",
/*backendType=*/2, /*pid=*/4242, &config);
auto envelope = CreateCtrlEnvelope(builder, CtrlMsg::Hello, hello.Union());
FinishCtrlEnvelopeBuffer(builder, envelope);
const std::uint8_t* begin = builder.GetBufferPointer();
return std::vector<std::uint8_t>(begin, begin + builder.GetSize());
}
} // namespace
TEST(ProtocolSmokeTest, HelloRoundTrips) {
const std::vector<std::uint8_t> buffer = BuildHello();
// Every message from the peer is verified before a single field is read:
// the control plane is parsed from another process's memory.
::flatbuffers::Verifier verifier(buffer.data(), buffer.size());
ASSERT_TRUE(VerifyCtrlEnvelopeBuffer(verifier));
ASSERT_TRUE(CtrlEnvelopeBufferHasIdentifier(buffer.data()));
const CtrlEnvelope* envelope = GetCtrlEnvelope(buffer.data());
ASSERT_NE(envelope, nullptr);
ASSERT_EQ(envelope->msg_type(), CtrlMsg::Hello);
const Hello* hello = envelope->msg_as_Hello();
ASSERT_NE(hello, nullptr);
EXPECT_EQ(hello->abiMajor(), static_cast<std::uint32_t>(MOBILEGL_PROTOCOL_ABI_MAJOR));
EXPECT_EQ(hello->abiMinor(), static_cast<std::uint32_t>(MOBILEGL_PROTOCOL_ABI_MINOR));
ASSERT_NE(hello->buildFingerprint(), nullptr);
EXPECT_EQ(hello->buildFingerprint()->str(), "mobilegl-test-build");
EXPECT_EQ(hello->backendType(), 2u);
EXPECT_EQ(hello->pid(), 4242u);
ASSERT_NE(hello->configBlob(), nullptr);
ASSERT_EQ(hello->configBlob()->size(), 4u);
EXPECT_EQ(hello->configBlob()->Get(3), 4u);
// A message of the wrong kind reads back as null rather than as garbage.
EXPECT_EQ(envelope->msg_as_Welcome(), nullptr);
}
TEST(ProtocolSmokeTest, WelcomeCarriesTheFourSegmentAnnouncements) {
::flatbuffers::FlatBufferBuilder builder(1024);
auto cmd = CreateSegmentRefDirect(builder, 1, SegmentKind::Cmd, 8ull * 1024 * 1024, "cmd");
auto stage = CreateSegmentRefDirect(builder, 2, SegmentKind::Stage, 32ull * 1024 * 1024, "stage");
auto reply = CreateSegmentRefDirect(builder, 3, SegmentKind::Reply, 8ull * 1024 * 1024, "reply");
auto event = CreateSegmentRefDirect(builder, 4, SegmentKind::Event, 256ull * 1024, "event");
auto welcome = CreateWelcome(builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR,
/*serverPid=*/99, cmd, stage, reply, event);
auto envelope = CreateCtrlEnvelope(builder, CtrlMsg::Welcome, welcome.Union());
FinishCtrlEnvelopeBuffer(builder, envelope);
::flatbuffers::Verifier verifier(builder.GetBufferPointer(), builder.GetSize());
ASSERT_TRUE(VerifyCtrlEnvelopeBuffer(verifier));
const Welcome* parsed = GetCtrlEnvelope(builder.GetBufferPointer())->msg_as_Welcome();
ASSERT_NE(parsed, nullptr);
EXPECT_EQ(parsed->serverPid(), 99u);
ASSERT_NE(parsed->cmdRing(), nullptr);
EXPECT_EQ(parsed->cmdRing()->kind(), SegmentKind::Cmd);
EXPECT_EQ(parsed->cmdRing()->sizeBytes(), 8ull * 1024 * 1024);
ASSERT_NE(parsed->stageRing(), nullptr);
EXPECT_EQ(parsed->stageRing()->sizeBytes(), 32ull * 1024 * 1024);
ASSERT_NE(parsed->eventRing(), nullptr);
EXPECT_EQ(parsed->eventRing()->sizeBytes(), 256ull * 1024);
}
TEST(ProtocolSmokeTest, UnionTagsAreFrozenWireValues) {
// Appending to CtrlMsg is a compatible change; reordering it is not. If
// this test has to be edited, the schema change was a wire break.
EXPECT_EQ(static_cast<int>(CtrlMsg::NONE), 0);
EXPECT_EQ(static_cast<int>(CtrlMsg::Hello), 1);
EXPECT_EQ(static_cast<int>(CtrlMsg::Welcome), 2);
EXPECT_EQ(static_cast<int>(CtrlMsg::CapsSnapshot), 3);
EXPECT_EQ(static_cast<int>(CtrlMsg::SurfaceOp), 4);
EXPECT_EQ(static_cast<int>(CtrlMsg::SurfaceReply), 5);
EXPECT_EQ(static_cast<int>(CtrlMsg::ResyncRequest), 6);
EXPECT_EQ(static_cast<int>(CtrlMsg::ResyncDone), 7);
EXPECT_EQ(static_cast<int>(CtrlMsg::AuxRequest), 8);
EXPECT_EQ(static_cast<int>(CtrlMsg::Fatal), 9);
EXPECT_EQ(static_cast<int>(CtrlMsg::LogLine), 10);
EXPECT_EQ(static_cast<int>(SegmentKind::Cmd), 1);
EXPECT_EQ(static_cast<int>(SegmentKind::Adopt), 6);
EXPECT_EQ(static_cast<int>(LogLevel::Error), 3);
EXPECT_EQ(static_cast<int>(FatalCode::ProtocolCorruption), 1);
}
TEST(ProtocolSmokeTest, TruncatedMessageFailsVerificationInsteadOfReadingGarbage) {
std::vector<std::uint8_t> buffer = BuildHello();
ASSERT_GT(buffer.size(), 8u);
buffer.resize(buffer.size() / 2);
::flatbuffers::Verifier verifier(buffer.data(), buffer.size());
EXPECT_FALSE(VerifyCtrlEnvelopeBuffer(verifier));
}
TEST(ProtocolSmokeTest, TravelsAcrossTheTransportUnchanged) {
std::unique_ptr<Transport::InProcessTransport> client;
std::unique_ptr<Transport::InProcessTransport> server;
Transport::InProcessTransport::CreatePair(client, server);
const std::vector<std::uint8_t> sent = BuildHello();
ASSERT_EQ(client->SendFrame(MobileGLByteSpan{sent.data(), sent.size()}), MOBILEGL_OK);
const std::uint64_t pending = server->PeekFrameSize();
ASSERT_EQ(pending, sent.size());
std::vector<std::uint8_t> received(pending);
std::uint64_t size = 0;
MobileGLMutableByteSpan span{received.data(), received.size()};
ASSERT_EQ(server->ReceiveFrame(span, &size, 1000), MOBILEGL_OK);
ASSERT_EQ(size, sent.size());
::flatbuffers::Verifier verifier(received.data(), received.size());
ASSERT_TRUE(VerifyCtrlEnvelopeBuffer(verifier));
const Hello* hello = GetCtrlEnvelope(received.data())->msg_as_Hello();
ASSERT_NE(hello, nullptr);
EXPECT_EQ(hello->pid(), 4242u);
}
+519
View File
@@ -0,0 +1,519 @@
// MobileGL - MobileGL/MG_Test/Wire/RingTest.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 SEG_CMD/SEG_STAGE SPSC ring: layout of the shared control page, cursor
// invariants, wrap-around, backpressure, the generation bump after a hard
// drain, and a real two-thread producer/consumer run.
#include <MG_Remote/Transport/Doorbell.h>
#include <MG_Remote/Transport/Ring.h>
#include <gtest/gtest.h>
#include <atomic>
#include <cstring>
#include <thread>
#include <vector>
using namespace MobileGL::MG_Remote::Transport;
namespace {
// A ring plus its control page, sized like a small SEG_CMD.
class RingFixture {
public:
explicit RingFixture(std::uint64_t capacity, RingCursorSet cursors = RingCursorSet::Cmd)
: m_bytes(static_cast<std::size_t>(capacity)), m_capacity(capacity) {
InitRingControl(m_control);
m_producer = RingProducer(&m_control, m_bytes.data(), capacity, cursors);
m_consumer = RingConsumer(&m_control, m_bytes.data(), capacity, cursors);
m_cursors = cursors;
}
RingControl& Control() { return m_control; }
RingProducer& Producer() { return m_producer; }
RingConsumer& Consumer() { return m_consumer; }
std::uint64_t Capacity() const { return m_capacity; }
bool Invariants() const { return RingCursorsValid(m_control, m_cursors, m_capacity); }
// Writes one record whose payload is `size` bytes of a recognisable
// pattern seeded by `seed`.
bool WriteRecord(std::uint16_t kind, std::uint64_t size, std::uint8_t seed) {
void* payload = m_producer.Reserve(kind, kRecNone, size);
if (payload == nullptr) {
return false;
}
auto* bytes = static_cast<std::uint8_t*>(payload);
for (std::uint64_t i = 0; i < size; ++i) {
bytes[i] = static_cast<std::uint8_t>(seed + i);
}
m_producer.Publish();
return true;
}
static bool CheckPattern(const RingRecordView& view, std::uint64_t size, std::uint8_t seed) {
const auto* bytes = static_cast<const std::uint8_t*>(view.payload);
for (std::uint64_t i = 0; i < size; ++i) {
if (bytes[i] != static_cast<std::uint8_t>(seed + i)) {
return false;
}
}
return true;
}
private:
alignas(4096) RingControl m_control{};
std::vector<std::uint8_t> m_bytes;
RingProducer m_producer;
RingConsumer m_consumer;
std::uint64_t m_capacity;
RingCursorSet m_cursors = RingCursorSet::Cmd;
};
} // namespace
TEST(RingTest, ControlPageLayoutIsTheSharedContract) {
// The page is mapped by two processes; its size and alignment are wire
// contract, not an implementation detail.
EXPECT_EQ(sizeof(RingControl), 4096u);
EXPECT_EQ(alignof(RingControl), 4096u);
EXPECT_EQ(sizeof(RingRecordHeader), 8u);
alignas(4096) RingControl control{};
InitRingControl(control);
// Zero is reserved for "uninitialized" on both generations.
EXPECT_EQ(control.serverEpoch.load(), 1u);
EXPECT_EQ(control.ringGeneration.load(), 1u);
EXPECT_EQ(control.cmdHead.load(), 0u);
EXPECT_EQ(control.stageHead.load(), 0u);
EXPECT_EQ(control.consumerParked.load(), 0u);
EXPECT_EQ(control.producerParked.load(), 0u);
EXPECT_EQ(control.eventRingFull.load(), 0u);
EXPECT_EQ(control.eventDropped.load(), 0u);
// Each contended group on its own cache line.
const auto offset = [&control](const void* member) {
return reinterpret_cast<const std::uint8_t*>(member) -
reinterpret_cast<const std::uint8_t*>(&control);
};
EXPECT_EQ(offset(&control.cmdHead) % 64, 0);
EXPECT_EQ(offset(&control.cmdAppliedTail) % 64, 0);
EXPECT_EQ(offset(&control.stageHead) % 64, 0);
EXPECT_EQ(offset(&control.stageAppliedTail) % 64, 0);
EXPECT_EQ(offset(&control.appliedSeq) % 64, 0);
EXPECT_EQ(offset(&control.serverEpoch) % 64, 0);
// cmdHead and cmdAppliedTail are written by different processes: they must
// not share a line.
EXPECT_NE(offset(&control.cmdHead) / 64, offset(&control.cmdAppliedTail) / 64);
}
TEST(RingTest, RejectsANonPowerOfTwoCapacity) {
alignas(4096) RingControl control{};
InitRingControl(control);
std::vector<std::uint8_t> bytes(1000);
RingProducer producer(&control, bytes.data(), 1000, RingCursorSet::Cmd);
EXPECT_FALSE(producer.Valid());
EXPECT_EQ(producer.Reserve(1, kRecNone, 8), nullptr);
}
TEST(RingTest, RejectsACapacityTheRecordHeaderCannotDescribe) {
alignas(4096) RingControl control{};
InitRingControl(control);
// 4 GiB is a legal power of two, but RingRecordHeader::size is 32 bits and
// both a record's size and a wrap filler's size are bounded only by the
// capacity: they would be truncated on the way in and then bounds-checked
// in their truncated form on the way out. Nothing is mapped here - the
// constructor rejects before it ever touches the base pointer.
std::uint8_t dummy = 0;
constexpr std::uint64_t kFourGiB = 4ull * 1024 * 1024 * 1024;
EXPECT_GT(kFourGiB, kMaxRingCapacity);
RingProducer producer(&control, &dummy, kFourGiB, RingCursorSet::Cmd);
EXPECT_FALSE(producer.Valid());
RingConsumer consumer(&control, &dummy, kFourGiB, RingCursorSet::Cmd);
EXPECT_FALSE(consumer.Valid());
// The largest ring the header CAN describe stays accepted.
RingProducer biggest(&control, &dummy, 1ull << 31, RingCursorSet::Cmd);
EXPECT_TRUE(biggest.Valid());
}
TEST(RingTest, RoundTripsRecordsInOrder) {
RingFixture ring(4096);
ASSERT_TRUE(ring.WriteRecord(1, 16, 0x10));
ASSERT_TRUE(ring.WriteRecord(2, 24, 0x20));
EXPECT_TRUE(ring.Invariants());
RingRecordView view{};
bool corrupt = false;
ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt));
EXPECT_FALSE(corrupt);
EXPECT_EQ(view.kind, 1u);
EXPECT_EQ(view.payloadSize, 16u);
EXPECT_TRUE(RingFixture::CheckPattern(view, 16, 0x10));
ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt));
EXPECT_EQ(view.kind, 2u);
EXPECT_EQ(view.payloadSize, 24u);
EXPECT_TRUE(RingFixture::CheckPattern(view, 24, 0x20));
EXPECT_FALSE(ring.Consumer().Pop(view, &corrupt));
ring.Consumer().PublishRetired();
EXPECT_TRUE(ring.Invariants());
EXPECT_EQ(ring.Control().cmdAppliedTail.load(), ring.Control().cmdHead.load());
EXPECT_EQ(ring.Control().cmdRetiredTail.load(), ring.Control().cmdHead.load());
}
TEST(RingTest, PayloadIsPaddedToTheRecordAlignment) {
RingFixture ring(4096);
ASSERT_TRUE(ring.WriteRecord(7, 3, 0x77));
RingRecordView view{};
ASSERT_TRUE(ring.Consumer().Pop(view));
// 8 (header) + 3 rounded up to 16 -> 8 bytes of payload space.
EXPECT_EQ(view.payloadSize, 8u);
EXPECT_TRUE(RingFixture::CheckPattern(view, 3, 0x77));
}
TEST(RingTest, WrapsWithoutSplittingARecord) {
// Small ring, records that do not divide it evenly, so the wrap boundary
// lands mid-record and the pad path is exercised many times.
RingFixture ring(256);
std::uint8_t seed = 0;
for (int i = 0; i < 200; ++i) {
const std::uint64_t size = 24 + (i % 5) * 8;
ASSERT_TRUE(ring.WriteRecord(static_cast<std::uint16_t>(1 + (i % 3)), size, seed))
<< "record " << i;
RingRecordView view{};
bool corrupt = false;
ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt)) << "record " << i;
ASSERT_FALSE(corrupt);
EXPECT_EQ(view.kind, static_cast<std::uint16_t>(1 + (i % 3)));
// Contiguity: the payload never straddles the end of the mapping.
EXPECT_TRUE(RingFixture::CheckPattern(view, size, seed)) << "record " << i;
ring.Consumer().PublishRetired();
ASSERT_TRUE(ring.Invariants());
seed = static_cast<std::uint8_t>(seed + 13);
}
// Cursors are monotonic byte counts, so they are far past the capacity.
EXPECT_GT(ring.Control().cmdHead.load(), ring.Capacity());
}
TEST(RingTest, FullRingRefusesAndRecoversWhenTheConsumerRetires) {
RingFixture ring(256);
int written = 0;
while (ring.WriteRecord(1, 24, static_cast<std::uint8_t>(written))) {
++written;
ASSERT_LT(written, 100);
}
EXPECT_GT(written, 0);
// Backpressure, not corruption.
EXPECT_TRUE(ring.Invariants());
EXPECT_LT(ring.Producer().FreeBytes(), 32u);
RingRecordView view{};
ASSERT_TRUE(ring.Consumer().Pop(view));
// Applied alone does not free a slot that may still be borrowed by the GPU
// timeline: reclaim follows the retired cursor.
ring.Consumer().PublishApplied();
EXPECT_EQ(ring.Producer().FreeBytes(), 0u);
ring.Consumer().PublishRetired();
EXPECT_GT(ring.Producer().FreeBytes(), 0u);
EXPECT_TRUE(ring.WriteRecord(1, 24, 0xEE));
}
TEST(RingTest, RecordLargerThanTheRingIsRefused) {
RingFixture ring(256);
EXPECT_EQ(ring.Producer().Reserve(1, kRecNone, 4096), nullptr);
EXPECT_TRUE(ring.Invariants());
}
TEST(RingTest, RecordLargerThanHalfTheRingIsRefused) {
// 256-byte ring: the bound is 128 bytes of header + payload.
RingFixture ring(256);
EXPECT_EQ(ring.Producer().MaxRecordBytes(), 128u);
// 8 + 240 = 248: fits the whole ring, does not fit half of it.
EXPECT_EQ(ring.Producer().Reserve(1, kRecNone, 240), nullptr);
// 8 + 128 = 136: one step over the bound, refused the same way...
EXPECT_EQ(ring.Producer().Reserve(1, kRecNone, 128), nullptr);
// ...and 8 + 120 = 128, exactly the bound, is accepted.
EXPECT_NE(ring.Producer().Reserve(1, kRecNone, 120), nullptr);
EXPECT_TRUE(ring.Invariants());
}
// The scenario that motivated the bound, as the negative control. Whether a record
// can be placed must not depend on where the head happens to be. With "total <=
// capacity" as the only rule, a 248-byte record is accepted at head offset 0 of an
// empty 256-byte ring and refused forever at head offset 16 of the same empty
// ring - it would need a 240-byte wrap pad plus itself, 488 bytes - while
// FreeBytes() reports 256 the whole time, so a producer waiting for FreeBytes()
// >= 248 spins on nullptr with nothing logged. Both answers have to be the same
// refusal, and it has to be the loud one.
TEST(RingTest, RecordPlaceabilityDoesNotDependOnTheHeadOffset) {
RingFixture atOffsetZero(256);
void* atZero = atOffsetZero.Producer().Reserve(1, kRecNone, 240);
RingFixture atOffsetSixteen(256);
ASSERT_TRUE(atOffsetSixteen.WriteRecord(1, 8, 0x01)); // 8 + 8 = 16 bytes
RingRecordView view{};
ASSERT_TRUE(atOffsetSixteen.Consumer().Pop(view));
atOffsetSixteen.Consumer().PublishRetired();
ASSERT_EQ(atOffsetSixteen.Producer().LocalHead(), 16u);
ASSERT_EQ(atOffsetSixteen.Producer().FreeBytes(), 256u);
void* atSixteen = atOffsetSixteen.Producer().Reserve(1, kRecNone, 240);
EXPECT_EQ(atSixteen, nullptr);
EXPECT_EQ(atZero, nullptr)
<< "a 248-byte record was accepted at head offset 0 but is unplaceable at head offset 16 of "
"the same empty ring: the emitter cannot tell a refusal it must chunk from a full ring it "
"must wait on";
EXPECT_TRUE(atOffsetZero.Invariants());
EXPECT_TRUE(atOffsetSixteen.Invariants());
}
// The positive half of the same argument: a record of exactly half the capacity is
// placeable at EVERY head offset of an empty ring, because the wrap pad in front of
// it costs at most total-8 bytes. Walk the head to each 8-byte offset with bare
// header records and reserve the maximal record there.
TEST(RingTest, HalfCapacityRecordFitsAtEveryHeadOffset) {
RingFixture ring(256);
const std::uint64_t mask = ring.Capacity() - 1;
const std::uint64_t maximal = ring.Producer().MaxRecordBytes() - sizeof(RingRecordHeader); // 120
for (std::uint64_t target = 0; target < ring.Capacity(); target += 8) {
// A bare header never straddles the boundary, so no pad appears on the way.
while ((ring.Producer().LocalHead() & mask) != target) {
ASSERT_TRUE(ring.WriteRecord(1, 0, 0));
RingRecordView filler{};
ASSERT_TRUE(ring.Consumer().Pop(filler));
ring.Consumer().PublishRetired();
}
ASSERT_EQ(ring.Producer().FreeBytes(), ring.Capacity()) << "head offset " << target;
void* payload = ring.Producer().Reserve(2, kRecNone, maximal);
ASSERT_NE(payload, nullptr) << "head offset " << target;
ring.Producer().Publish();
RingRecordView view{};
bool corrupt = false;
ASSERT_TRUE(ring.Consumer().Pop(view, &corrupt)) << "head offset " << target;
ASSERT_FALSE(corrupt);
EXPECT_EQ(view.kind, 2u);
EXPECT_EQ(view.payloadSize, maximal);
ring.Consumer().PublishRetired();
ASSERT_TRUE(ring.Invariants()) << "head offset " << target;
}
}
TEST(RingTest, RejectsARingTooSmallForTheSmallestRecord) {
alignas(4096) RingControl control{};
InitRingControl(control);
std::uint8_t bytes[16] = {};
// One header's worth of ring can carry nothing once a record may be at most
// half the ring; two headers' worth carries a bare header.
RingProducer tooSmall(&control, bytes, sizeof(RingRecordHeader), RingCursorSet::Cmd);
EXPECT_FALSE(tooSmall.Valid());
RingProducer smallest(&control, bytes, kMinRingCapacity, RingCursorSet::Cmd);
ASSERT_TRUE(smallest.Valid());
EXPECT_EQ(smallest.MaxRecordBytes(), sizeof(RingRecordHeader));
EXPECT_NE(smallest.Reserve(1, kRecNone, 0), nullptr);
}
TEST(RingTest, HardDrainBumpsTheGenerationOnlyWhenQuiesced) {
RingFixture ring(256);
ASSERT_TRUE(ring.WriteRecord(1, 32, 0x01));
const std::uint32_t before = ring.Control().ringGeneration.load();
// Records still in flight: the drain is refused and nothing changes.
EXPECT_EQ(HardDrainRing(ring.Control(), RingCursorSet::Cmd), MOBILEGL_ERR_INVALID_ARGUMENT);
EXPECT_EQ(ring.Control().ringGeneration.load(), before);
RingRecordView view{};
ASSERT_TRUE(ring.Consumer().Pop(view));
ring.Consumer().PublishRetired();
EXPECT_EQ(HardDrainRing(ring.Control(), RingCursorSet::Cmd), MOBILEGL_OK);
EXPECT_EQ(ring.Control().ringGeneration.load(), before + 1);
// Cursors stay monotonic across the drain - only the generation moves.
EXPECT_EQ(ring.Control().cmdHead.load(), ring.Control().cmdAppliedTail.load());
EXPECT_GT(ring.Control().cmdHead.load(), 0u);
}
TEST(RingTest, CorruptHeaderIsRefusedRatherThanDispatched) {
// SEG_CMD is written by the peer process, so a compile-time size assert on
// the record catalogue proves nothing about what is actually in the
// mapping. Hand-build a ring whose first header is impossible (a size that
// is not a multiple of 8) and check the consumer refuses it instead of
// dispatching into undefined behaviour.
alignas(4096) RingControl control{};
InitRingControl(control);
std::vector<std::uint8_t> bytes(256, 0);
RingRecordHeader bad{};
bad.kind = 5;
bad.flags = kRecNone;
bad.size = 13; // not 8-aligned
std::memcpy(bytes.data(), &bad, sizeof(bad));
control.cmdHead.store(64, std::memory_order_release);
RingConsumer consumer(&control, bytes.data(), bytes.size(), RingCursorSet::Cmd);
RingRecordView view{};
bool corrupt = false;
EXPECT_FALSE(consumer.Pop(view, &corrupt));
EXPECT_TRUE(corrupt);
// A record claiming more bytes than the producer has published is the same
// class of violation and is refused the same way.
bad.size = 128;
std::memcpy(bytes.data(), &bad, sizeof(bad));
RingConsumer second(&control, bytes.data(), bytes.size(), RingCursorSet::Cmd);
corrupt = false;
EXPECT_FALSE(second.Pop(view, &corrupt));
EXPECT_TRUE(corrupt);
}
TEST(RingTest, SpscProducerConsumerThreadsAgreeOnEveryRecord) {
constexpr int kRecords = 20000;
RingFixture ring(4096);
std::atomic<bool> failed{false};
std::atomic<int> consumed{0};
std::thread consumer([&] {
int next = 0;
while (next < kRecords) {
RingRecordView view{};
bool corrupt = false;
if (!ring.Consumer().Pop(view, &corrupt)) {
if (corrupt) {
failed.store(true);
return;
}
std::this_thread::yield();
continue;
}
const std::uint32_t expectedKind = static_cast<std::uint16_t>(1 + (next % 7));
if (view.kind != expectedKind || view.payloadSize < sizeof(std::uint32_t)) {
failed.store(true);
return;
}
std::uint32_t value = 0;
std::memcpy(&value, view.payload, sizeof(value));
if (value != static_cast<std::uint32_t>(next)) {
failed.store(true);
return;
}
++next;
consumed.store(next, std::memory_order_relaxed);
// Retire as we go; a consumer that never retires would deadlock the
// producer, which is exactly the contract being pinned.
ring.Consumer().PublishRetired();
}
});
for (int i = 0; i < kRecords; ++i) {
const std::uint64_t payloadSize = sizeof(std::uint32_t) + (i % 4) * 8;
void* payload = nullptr;
while ((payload = ring.Producer().Reserve(static_cast<std::uint16_t>(1 + (i % 7)),
kRecNone, payloadSize)) == nullptr) {
if (failed.load()) {
break;
}
std::this_thread::yield();
}
if (payload == nullptr) {
break;
}
const std::uint32_t value = static_cast<std::uint32_t>(i);
std::memcpy(payload, &value, sizeof(value));
ring.Producer().Publish();
}
consumer.join();
EXPECT_FALSE(failed.load());
EXPECT_EQ(consumed.load(), kRecords);
EXPECT_TRUE(ring.Invariants());
EXPECT_EQ(ring.Control().cmdRetiredTail.load(), ring.Control().cmdHead.load());
}
// The publish/park protocol end to end, in both directions: publish the
// watermark, THEN NotifyIfParked; park with Doorbell::Wait. A lost wakeup on
// either side shows up as a Wait that times out with work available rather
// than as a hang, so the failure is a red test and not a stuck CI job.
//
// This cannot prove the seq_cst fence pairing (no test can - x86 needs the
// store buffer to hold the release store across the flag read, and it usually
// does not), but it does exercise the exact call order the fences assume, so a
// future edit that rings the bell BEFORE publishing has somewhere to fail.
TEST(RingTest, DoorbellHandoffWakesBothSidesOnEveryPublish) {
// 4 byte payloads: every record is exactly 16 bytes and 4096 is a multiple
// of that, so no wrap filler ever appears and "head != tail" is exactly
// "a record is waiting".
RingFixture ring(4096);
CondVarDoorbell consumerBell;
CondVarDoorbell producerBell;
std::atomic<bool> ok{true};
constexpr int kRecords = 2000;
constexpr std::uint64_t kRecordBytes = 16;
std::thread consumerThread([&] {
int seen = 0;
while (seen < kRecords) {
const bool woke = consumerBell.Wait(
ring.Control().consumerParked,
[&] {
return ring.Control().cmdHead.load(std::memory_order_acquire) !=
ring.Consumer().LocalTail();
},
kDefaultSpinUs, 5000);
if (!woke) {
ok.store(false); // a wakeup was lost, or the producer stalled
return;
}
RingRecordView view{};
bool corrupt = false;
while (ring.Consumer().Pop(view, &corrupt)) {
std::uint32_t value = 0;
std::memcpy(&value, view.payload, sizeof(value));
if (value != static_cast<std::uint32_t>(seen)) {
ok.store(false);
return;
}
++seen;
}
if (corrupt) {
ok.store(false);
return;
}
ring.Consumer().PublishRetired();
NotifyIfParked(producerBell, ring.Control().producerParked);
}
});
for (int i = 0; i < kRecords && ok.load(); ++i) {
void* payload = nullptr;
while ((payload = ring.Producer().Reserve(1, kRecNone, sizeof(std::uint32_t))) == nullptr) {
if (!ok.load()) {
break;
}
if (!producerBell.Wait(
ring.Control().producerParked,
[&] { return ring.Producer().FreeBytes() >= kRecordBytes; }, kDefaultSpinUs,
5000)) {
ok.store(false);
break;
}
}
if (payload == nullptr) {
break;
}
const std::uint32_t value = static_cast<std::uint32_t>(i);
std::memcpy(payload, &value, sizeof(value));
// Publish first, ring second. The other order reopens the lost-wakeup
// window no matter how strong the flag's memory order is.
ring.Producer().Publish();
NotifyIfParked(consumerBell, ring.Control().consumerParked);
}
consumerThread.join();
EXPECT_TRUE(ok.load());
EXPECT_TRUE(ring.Invariants());
}
@@ -1145,6 +1145,8 @@ namespace MobileGL::MG_Util::BackendLoader {
GLint maxFragmentShaderStorageBlocks = 4;
GLint maxComputeUniformBlocks = 12;
GLint maxComputeWorkGroupInvocations = 128;
GLint maxComputeWorkGroupCount[3] = {65535, 65535, 65535};
GLint maxComputeWorkGroupSize[3] = {1024, 1024, 64};
GLint maxShaderStorageBufferBindings = 8;
GLint maxTextureBufferSize = 65536;
GLint maxUniformBufferBindings = 24;
@@ -1276,6 +1278,17 @@ namespace MobileGL::MG_Util::BackendLoader {
glesFuncs.glGetIntegerv(GL_MAX_COMBINED_SHADER_STORAGE_BLOCKS, &maxCombinedShaderStorageBlocks);
glesFuncs.glGetIntegerv(GL_MAX_COMPUTE_UNIFORM_BLOCKS, &maxComputeUniformBlocks);
glesFuncs.glGetIntegerv(GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS, &maxComputeWorkGroupInvocations);
// The per-axis pair beside it, through the indexed query. ES 3.1 core like the
// invocations limit, so it sits inside the same bracketed run: a 3.0 context rejects
// it, the drain below swallows the error and the locals keep the GL 4.3 minimums.
// These are the six backend-owned indexed answers that cross the MGPipe boundary in
// MGPCaps (DynamicBackendParameters::MaxComputeWorkGroupCount/Size).
if (glesFuncs.glGetIntegeri_v) {
for (GLuint axis = 0; axis < 3; ++axis) {
glesFuncs.glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_COUNT, axis, &maxComputeWorkGroupCount[axis]);
glesFuncs.glGetIntegeri_v(GL_MAX_COMPUTE_WORK_GROUP_SIZE, axis, &maxComputeWorkGroupSize[axis]);
}
}
glesFuncs.glGetIntegerv(GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS, &maxShaderStorageBufferBindings);
// GL_MAX_TEXTURE_BUFFER_SIZE is deliberately NOT batched here: like
// GL_TEXTURE_BUFFER_OFFSET_ALIGNMENT below, the pname only exists once buffer textures do,
@@ -1584,6 +1597,10 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxFragmentShaderStorageBlocks = maxFragmentShaderStorageBlocks;
caps.MaxComputeUniformBlocks = maxComputeUniformBlocks;
caps.MaxComputeWorkGroupInvocations = maxComputeWorkGroupInvocations;
for (SizeT axis = 0; axis < 3; ++axis) {
caps.MaxComputeWorkGroupCount[axis] = maxComputeWorkGroupCount[axis];
caps.MaxComputeWorkGroupSize[axis] = maxComputeWorkGroupSize[axis];
}
caps.MaxShaderStorageBufferBindings = maxShaderStorageBufferBindings;
caps.MaxTextureBufferSize = maxTextureBufferSize;
// Through glesFuncs, like every other capability query here: a bare glGetIntegerv resolves
@@ -1681,6 +1698,10 @@ namespace MobileGL::MG_Util::BackendLoader {
MGLOG_I(" GL_MAX_FRAGMENT_SHADER_STORAGE_BLOCKS: %d", caps.MaxFragmentShaderStorageBlocks);
MGLOG_I(" GL_MAX_COMPUTE_UNIFORM_BLOCKS: %d", caps.MaxComputeUniformBlocks);
MGLOG_I(" GL_MAX_COMPUTE_WORK_GROUP_INVOCATIONS: %d", caps.MaxComputeWorkGroupInvocations);
MGLOG_I(" GL_MAX_COMPUTE_WORK_GROUP_COUNT: %d %d %d", caps.MaxComputeWorkGroupCount[0],
caps.MaxComputeWorkGroupCount[1], caps.MaxComputeWorkGroupCount[2]);
MGLOG_I(" GL_MAX_COMPUTE_WORK_GROUP_SIZE: %d %d %d", caps.MaxComputeWorkGroupSize[0],
caps.MaxComputeWorkGroupSize[1], caps.MaxComputeWorkGroupSize[2]);
MGLOG_I(" GL_MAX_SHADER_STORAGE_BUFFER_BINDINGS: %d", caps.MaxShaderStorageBufferBindings);
// Three distinct states, and the suffix must not conflate them: a driver answer, a floor
// kept because there are no buffer textures to ask about, and a floor kept because the
@@ -1309,6 +1309,11 @@ namespace MobileGL {
Int MaxFragmentShaderStorageBlocks = 4;
Int MaxComputeUniformBlocks = 12;
Int MaxComputeWorkGroupInvocations = 128;
// GL_MAX_COMPUTE_WORK_GROUP_COUNT / _SIZE per axis, as the driver answers
// glGetIntegeri_v. Raw: the frontend floors them at the GL minimums itself. The
// initialisers are those minimums, for a context that rejects the query.
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.
@@ -196,6 +196,10 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxCombinedShaderStorageBlocks = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers);
caps.MaxComputeUniformBlocks = SaturateToInt(p.limits.maxPerStageDescriptorUniformBuffers);
caps.MaxComputeWorkGroupInvocations = SaturateToInt(p.limits.maxComputeWorkGroupInvocations);
for (SizeT axis = 0; axis < 3; ++axis) {
caps.MaxComputeWorkGroupCount[axis] = SaturateToInt(p.limits.maxComputeWorkGroupCount[axis]);
caps.MaxComputeWorkGroupSize[axis] = SaturateToInt(p.limits.maxComputeWorkGroupSize[axis]);
}
caps.MaxShaderStorageBufferBindings = SaturateToInt(p.limits.maxDescriptorSetStorageBuffers);
caps.MaxTextureBufferSize = SaturateToInt(p.limits.maxTexelBufferElements);
caps.TextureBufferOffsetAlignment =
@@ -333,6 +337,10 @@ namespace MobileGL::MG_Util::BackendLoader {
caps.MaxCombinedShaderStorageBlocks = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers);
caps.MaxComputeUniformBlocks = SaturateToInt(properties.limits.maxPerStageDescriptorUniformBuffers);
caps.MaxComputeWorkGroupInvocations = SaturateToInt(properties.limits.maxComputeWorkGroupInvocations);
for (SizeT axis = 0; axis < 3; ++axis) {
caps.MaxComputeWorkGroupCount[axis] = SaturateToInt(properties.limits.maxComputeWorkGroupCount[axis]);
caps.MaxComputeWorkGroupSize[axis] = SaturateToInt(properties.limits.maxComputeWorkGroupSize[axis]);
}
caps.MaxShaderStorageBufferBindings = SaturateToInt(properties.limits.maxDescriptorSetStorageBuffers);
caps.MaxTextureBufferSize = SaturateToInt(properties.limits.maxTexelBufferElements);
caps.TextureBufferOffsetAlignment =
@@ -56,6 +56,11 @@ namespace MobileGL {
Int MaxCombinedShaderStorageBlocks = 32;
Int MaxComputeUniformBlocks = 12;
Int MaxComputeWorkGroupInvocations = 128;
// VkPhysicalDeviceLimits::maxComputeWorkGroupCount / maxComputeWorkGroupSize per
// axis, saturated to Int like every other limit here. Raw: the frontend floors them
// at the GL minimums itself.
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.
+488
View File
@@ -0,0 +1,488 @@
// MobileGL - MobileGL/MG_Util/Metrics/PipeStats.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 "PipeStats.h"
#include <Config.h>
#include <fstream>
// ---------------------------------------------------------------------------------------
// SITE INVENTORY - what these counters DO and DO NOT cover.
//
// This list is the contract. A byte class that reads 0 while a real copy runs uncounted is
// worse than a missing counter, because the zero is then read as an answer, so every path
// that moves bytes and is NOT wired is named here by file and function.
//
// Byte classes
// stage-buffer ESPRYT (DirectGLES Managers.cpp): RespecifyStorageNow's
// glBufferData, FlushPendingRangesNow's three shapes (map-write,
// glBufferSubData, upload-ring stage), and the pool-recycle reseed
// in SyncBufferObject.
// MAGMA (DirectVulkan VkBufferManager.cpp): every host->device copy
// of a buffer object's contents - SwapStorageAndUploadAll, the
// StagedRangeCopy staging fill, the in-place uploads in OnRespecify /
// OnSubData / OnFlushMappedRange, the AcquirePersistentMap seed, the
// AcquireResidentSlice initial upload and the AcquireStreamedSlice
// arena fill.
// NOT covered: bytes an app writes THROUGH a persistent map. Those
// never pass through either backend (D4/D-B4) - see
// persistent-map-push.
// stage-texture ESPRYT (Managers.cpp texture upload): the bytes of whichever of
// the three upload shapes ran (rect list / union box / whole level).
// MAGMA (VkTextureManager.cpp): the packed staging slice of an
// upload batch item set.
// NOT covered: Espryt's compressed-texture path, and both backends'
// readback (device->host) paths, which are a different direction and
// want their own class when the reverse channel of section 7 exists.
// stage-ubo-global ESPRYT (DirectGLES.cpp): the default-uniform-block image, both the
// UBO-ring memcpy and the glBufferSubData fallback.
// MAGMA (UniformManager::ResolveDynamicUboDescriptor): the same
// image, counted after the per-frame slice memo, so a frame that
// re-uses the slice correctly contributes nothing.
// stage-ubo-named DirectVulkan UniformManager::ResolveUniformBufferPayload - the
// bytes Magma repacks into its own UBO ring, counted AFTER the
// zero-copy direct-bind decision (a direct bind repacks nothing).
// Espryt contributes nothing by construction (D-B8).
// stage-vertex-client ESPRYT: BackendVertexArrayObject::SyncClientSideAttributesFor-
// DrawArrays (both the Float64-narrowing and the verbatim shapes)
// and the VBO-backed Float64->Float32 narrowing scratch upload.
// MAGMA: VkBufferManager::UploadTransient(BufferKind::Vertex), which
// is the single chokepoint for the converted-vertex-stream and
// client-array staging.
// stage-index-client ESPRYT: the primitive-restart substitution buffer, and MultiDraw's
// rewritten (rebased) index stream.
// MAGMA: VkBufferManager::UploadTransient(BufferKind::Index).
// stage-indirect-cmd ESPRYT MultiDraw.cpp: the DrawElementsIndirectCommand array staged
// for the indirect tiers, and the compute tier's per-draw info
// array. Kept out of stage-index-client because these are draw
// PARAMETERS - the population that becomes MGPipe command-record
// payload, not resource bytes.
// NOT covered: Magma builds no such array (it issues one vkCmdDraw*
// per sub-draw), so this class is Espryt-only by construction.
// persistent-map-push Not wired in P0: today a persistent map is a permanent address
// space donation (D4/D-B4) that survives the whole monolith track,
// so there is no push to count until the IPC track breaks it.
// residual-value-block Placeholder, always 0 until P2 (plan section 6.3).
//
// Call classes
// draws DirectGLES PrepareForDraw and DirectVulkan SetupDraw's entry. A
// dispatch is not a draw and is not counted.
// accessor-calls STATIC TALLIES at the instrumented entry points, NOT a wrapper
// around all 293 pGLContext-> sites. Each instrumented function adds
// the number of GLContext accessor calls that its OWN body executed
// on the path taken, and each tally sits AFTER the last early return
// that would skip those reads. Covered: PrepareForDraw's own reads,
// SyncRenderState, CaptureDrawTextureSyncKeys/CurrentUnitBindings-
// Epoch, SyncNeccessaryTextures' walk, TrySetupDrawFastPath,
// GetOrCreatePipeline and ApplyDynamicDrawStateTail. NOT covered:
// the reads inside the callees those functions invoke (buffer/VAO/
// FBO/program sync, the pipeline payload builder's ~40 reads on a
// memo miss), and every non-draw entry point. The number is
// therefore a LOWER BOUND on the per-draw accessor count, and it is
// the bound over exactly the six gates section 2.3.1 tabulates.
// texture-* Per (target, level) emission, both backends.
//
// Gates: the six of section 2.3.1, each counted exactly once per probe.
//
// READING acc/draw. The accessor tally covers the instrumented functions wherever they
// run, and three of them (SyncRenderState, the texture-key capture, SyncNeccessaryTextures)
// are also reached from NON-draw call sites - Clear, readbacks, the DSA by-name entry
// points - which the `draws` counter deliberately does not count. So acc/draw is the
// per-draw steady-state number section 2.3.1 asks for only in a DRAW-DOMINATED window; in a
// window dominated by clears and readbacks it is inflated by exactly those non-draw
// probes, and the gate hit/miss pairs are the honest reading there.
// ---------------------------------------------------------------------------------------
namespace MobileGL::MG_Util::PipeStats {
Bool g_pipeStatsEnabled = false;
namespace {
constexpr Uint32 kByteClassCount = static_cast<Uint32>(ByteClass::Count);
constexpr Uint32 kCallClassCount = static_cast<Uint32>(CallClass::Count);
constexpr Uint32 kGateCount = static_cast<Uint32>(Gate::Count);
using Counter = std::atomic<Uint64>;
Counter g_frameBytes[kByteClassCount];
Counter g_totalBytes[kByteClassCount];
Counter g_frameCalls[kCallClassCount];
Counter g_totalCalls[kCallClassCount];
Counter g_frameGateHit[kGateCount];
Counter g_totalGateHit[kGateCount];
Counter g_frameGateMiss[kGateCount];
Counter g_totalGateMiss[kGateCount];
Counter g_totalPayloadBuckets[kPayloadHistogramBuckets];
Counter g_frameCount{0};
// Window bases: the run totals as of the previous summary line. Only ever touched
// from OnPresent()/Shutdown() (the present thread), so plain integers.
Uint64 g_windowBaseBytes[kByteClassCount] = {};
Uint64 g_windowBaseCalls[kCallClassCount] = {};
Uint64 g_windowBaseGateHit[kGateCount] = {};
Uint64 g_windowBaseGateMiss[kGateCount] = {};
Uint64 g_windowBaseFrames = 0;
Bool g_shutdownDone = false;
// Frames per summary line, latched by Init() from MOBILEGL_PIPE_STATS_PERIOD.
Uint64 g_summaryPeriod = kDefaultSummaryFramePeriod;
inline void Bump(Counter& counter, Uint64 amount) {
counter.fetch_add(amount, std::memory_order_relaxed);
}
inline Uint64 Read(const Counter& counter) { return counter.load(std::memory_order_relaxed); }
// Bucket 0 is "0 bytes", bucket n>0 holds [2^(n-1), 2^n). Saturates at the last
// bucket so a pathological record cannot index out of the array.
Uint32 PayloadBucketOf(Uint64 bytes) {
if (bytes == 0) {
return 0;
}
Uint32 bucket = 1;
while (bucket + 1 < kPayloadHistogramBuckets && bytes >= (Uint64{1} << bucket)) {
++bucket;
}
return bucket;
}
// Two decimals without <iomanip>. Every per-frame and per-draw field in the summary
// goes through this: the numbers are small (a per-draw accessor count in the 10-25
// band, a per-frame byte count that sizes SEG_STAGE), so truncating integer division
// loses up to a whole unit on exactly the figures the package exists to produce.
// A zero denominator is "n/a" rather than a division by a faked 1.
String FormatFixed2(Uint64 numerator, Uint64 denominator) {
if (denominator == 0) {
return "n/a";
}
const Uint64 hundredths = (numerator * 100 + denominator / 2) / denominator;
return std::to_string(hundredths / 100) + "." + (hundredths % 100 < 10 ? "0" : "") +
std::to_string(hundredths % 100);
}
const char* const kByteClassNames[kByteClassCount] = {
"stage-buffer", "stage-texture", "stage-ubo-global",
"stage-ubo-named", "stage-vertex-client", "stage-index-client",
"stage-indirect-cmd", "persistent-map-push", "residual-value-block",
};
const char* const kCallClassNames[kCallClassCount] = {
"draws", "accessor-calls", "tex-upload-emissions", "tex-upload-box", "tex-upload-rect",
"tex-upload-jobs",
};
const char* const kGateNames[kGateCount] = {
"espryt-render-state", "espryt-texture-sync-list", "espryt-unit-bindings-epoch",
"magma-draw-fastpath", "magma-pipeline-memo", "magma-dynamic-tail",
};
// Tracy needs a stable string literal per series, and a gate is TWO series: plotting
// only the misses (which is what the first cut did) hides the denominator, and a
// gate's whole point is the ratio.
const char* const kGateHitPlotNames[kGateCount] = {
"espryt-render-state-hit", "espryt-texture-sync-list-hit", "espryt-unit-bindings-epoch-hit",
"magma-draw-fastpath-hit", "magma-pipeline-memo-hit", "magma-dynamic-tail-hit",
};
const char* const kGateMissPlotNames[kGateCount] = {
"espryt-render-state-miss", "espryt-texture-sync-list-miss", "espryt-unit-bindings-epoch-miss",
"magma-draw-fastpath-miss", "magma-pipeline-memo-miss", "magma-dynamic-tail-miss",
};
// Short forms, so the per-120-frame line stays one terminal line wide.
const char* const kByteClassShort[kByteClassCount] = {"buf", "tex", "ubog", "ubon", "vtxc",
"idxc", "icmd", "pmap", "resid"};
const char* const kGateShort[kGateCount] = {"ers", "etl", "eub", "mfp", "mpm", "mdt"};
void ResetCounters() {
for (Uint32 i = 0; i < kByteClassCount; ++i) {
g_frameBytes[i].store(0, std::memory_order_relaxed);
g_totalBytes[i].store(0, std::memory_order_relaxed);
g_windowBaseBytes[i] = 0;
}
for (Uint32 i = 0; i < kCallClassCount; ++i) {
g_frameCalls[i].store(0, std::memory_order_relaxed);
g_totalCalls[i].store(0, std::memory_order_relaxed);
g_windowBaseCalls[i] = 0;
}
for (Uint32 i = 0; i < kGateCount; ++i) {
g_frameGateHit[i].store(0, std::memory_order_relaxed);
g_totalGateHit[i].store(0, std::memory_order_relaxed);
g_frameGateMiss[i].store(0, std::memory_order_relaxed);
g_totalGateMiss[i].store(0, std::memory_order_relaxed);
g_windowBaseGateHit[i] = 0;
g_windowBaseGateMiss[i] = 0;
}
for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) {
g_totalPayloadBuckets[i].store(0, std::memory_order_relaxed);
}
g_frameCount.store(0, std::memory_order_relaxed);
g_windowBaseFrames = 0;
}
void EmitSummaryLine() {
const String line = FormatWindowLine();
// MGLOG_I on purpose, against the project's usual "MGLOG_D for anything
// non-critical" rule: the line has to survive an INFO build (that is the only
// build a device ever runs), it is emitted at most once per 120 frames, and it
// exists at all only when the operator set MOBILEGL_PIPE_STATS=1. It is an
// opt-in measurement channel, not per-frame noise.
MGLOG_I("%s", line.c_str());
AdvanceSummaryWindow();
}
void WriteJsonDump() {
const String& path = MG_Config::Features.PipeStatsFile;
if (path.empty()) {
return;
}
std::ofstream out(path, std::ios::out | std::ios::trunc);
if (!out) {
MGLOG_W("PipeStats: could not open MOBILEGL_PIPE_STATS_FILE='%s' for writing", path.c_str());
return;
}
out << FormatJson();
out.flush();
if (!out) {
MGLOG_W("PipeStats: failed writing MOBILEGL_PIPE_STATS_FILE='%s'", path.c_str());
return;
}
MGLOG_I("MGPipe stats: wrote JSON dump to %s", path.c_str());
}
} // namespace
void Init() {
ResetCounters();
g_shutdownDone = false;
g_pipeStatsEnabled = MG_Config::Features.PipeStats;
g_summaryPeriod = MG_Config::Features.PipeStatsPeriod == 0
? kDefaultSummaryFramePeriod
: static_cast<Uint64>(MG_Config::Features.PipeStatsPeriod);
if (g_pipeStatsEnabled) {
MGLOG_I("MGPipe stats: counters ON (MOBILEGL_PIPE_STATS), summary every %llu frames%s%s",
static_cast<unsigned long long>(g_summaryPeriod),
MG_Config::Features.PipeStatsFile.empty() ? "" : ", JSON dump to ",
MG_Config::Features.PipeStatsFile.c_str());
}
}
Uint64 SummaryFramePeriod() { return g_summaryPeriod; }
void Shutdown() {
if (!g_pipeStatsEnabled || g_shutdownDone) {
return;
}
g_shutdownDone = true;
EmitSummaryLine();
WriteJsonDump();
}
void AddBytes(ByteClass byteClass, Uint64 bytes) {
const Uint32 index = static_cast<Uint32>(byteClass);
Bump(g_frameBytes[index], bytes);
Bump(g_totalBytes[index], bytes);
}
void AddCalls(CallClass callClass, Uint64 count) {
const Uint32 index = static_cast<Uint32>(callClass);
Bump(g_frameCalls[index], count);
Bump(g_totalCalls[index], count);
}
void CountGate(Gate gate, Bool hit) {
const Uint32 index = static_cast<Uint32>(gate);
if (hit) {
Bump(g_frameGateHit[index], 1);
Bump(g_totalGateHit[index], 1);
} else {
Bump(g_frameGateMiss[index], 1);
Bump(g_totalGateMiss[index], 1);
}
}
void RecordDrawPayloadBytes(Uint64 bytes) { Bump(g_totalPayloadBuckets[PayloadBucketOf(bytes)], 1); }
void OnPresent() {
// Every frame accumulator is EXCHANGED for zero, and the exchanged value is what gets
// plotted. A read followed by a store(0) would lose any Bump that lands in between -
// buffer and texture staging reach these counters from more than one thread - from
// the plot AND from every frame; an exchange hands every add to exactly one frame.
// Without Tracy the value is taken and dropped: the clear is still the point.
//
// One plot per counter, the frame's value. Tracy keeps the series by name, and the
// names are the static literals above, which is what TracyPlot requires. A gate is
// two series - hits and misses - because the ratio is the deliverable and a miss
// count alone cannot be read.
//
// The payload histogram is deliberately NOT plotted: it is a run-total distribution
// over draws (section 4.5.7), not a per-frame scalar, and Tracy has no histogram
// series. It reaches the operator through the JSON dump.
const auto take = [](Counter& counter) { return counter.exchange(0, std::memory_order_relaxed); };
for (Uint32 i = 0; i < kByteClassCount; ++i) {
const Uint64 value = take(g_frameBytes[i]);
(void)value;
#ifdef TRACY_ENABLE
TracyPlot(kByteClassNames[i], static_cast<Int64>(value));
#endif
}
for (Uint32 i = 0; i < kCallClassCount; ++i) {
const Uint64 value = take(g_frameCalls[i]);
(void)value;
#ifdef TRACY_ENABLE
TracyPlot(kCallClassNames[i], static_cast<Int64>(value));
#endif
}
for (Uint32 i = 0; i < kGateCount; ++i) {
const Uint64 hits = take(g_frameGateHit[i]);
const Uint64 misses = take(g_frameGateMiss[i]);
(void)hits;
(void)misses;
#ifdef TRACY_ENABLE
TracyPlot(kGateHitPlotNames[i], static_cast<Int64>(hits));
TracyPlot(kGateMissPlotNames[i], static_cast<Int64>(misses));
#endif
}
const Uint64 frames = g_frameCount.fetch_add(1, std::memory_order_relaxed) + 1;
if (frames % g_summaryPeriod == 0) {
EmitSummaryLine();
}
}
Uint64 FrameBytes(ByteClass byteClass) { return Read(g_frameBytes[static_cast<Uint32>(byteClass)]); }
Uint64 TotalBytes(ByteClass byteClass) { return Read(g_totalBytes[static_cast<Uint32>(byteClass)]); }
Uint64 FrameCalls(CallClass callClass) { return Read(g_frameCalls[static_cast<Uint32>(callClass)]); }
Uint64 TotalCalls(CallClass callClass) { return Read(g_totalCalls[static_cast<Uint32>(callClass)]); }
Uint64 TotalGateHits(Gate gate) { return Read(g_totalGateHit[static_cast<Uint32>(gate)]); }
Uint64 TotalGateMisses(Gate gate) { return Read(g_totalGateMiss[static_cast<Uint32>(gate)]); }
Uint64 TotalPayloadBucket(Uint32 bucket) {
return bucket < kPayloadHistogramBuckets ? Read(g_totalPayloadBuckets[bucket]) : 0;
}
Uint64 FrameCount() { return Read(g_frameCount); }
const char* NameOf(ByteClass byteClass) { return kByteClassNames[static_cast<Uint32>(byteClass)]; }
const char* NameOf(CallClass callClass) { return kCallClassNames[static_cast<Uint32>(callClass)]; }
const char* NameOf(Gate gate) { return kGateNames[static_cast<Uint32>(gate)]; }
String FormatWindowLine() {
// Window values: everything since the previous summary. A run total over a workload
// whose shape changes (load, then steady state) hides exactly the number P2 wants.
const Uint64 frames = Read(g_frameCount);
const Uint64 windowFrames = frames - g_windowBaseFrames;
// A window with no Present in it (teardown before the first frame, or a slice whose
// whole workload runs off-screen) has NO per-frame reading. Printing the window
// totals under a "/f" label there is how a 47x overstatement of the SEG_STAGE sizing
// input got printed as a per-frame figure; the label changes instead.
const Bool perFrame = windowFrames != 0;
Uint64 bytes[kByteClassCount];
for (Uint32 i = 0; i < kByteClassCount; ++i) {
bytes[i] = Read(g_totalBytes[i]) - g_windowBaseBytes[i];
}
Uint64 calls[kCallClassCount];
for (Uint32 i = 0; i < kCallClassCount; ++i) {
calls[i] = Read(g_totalCalls[i]) - g_windowBaseCalls[i];
}
Uint64 gateHit[kGateCount];
Uint64 gateMiss[kGateCount];
for (Uint32 i = 0; i < kGateCount; ++i) {
gateHit[i] = Read(g_totalGateHit[i]) - g_windowBaseGateHit[i];
gateMiss[i] = Read(g_totalGateMiss[i]) - g_windowBaseGateMiss[i];
}
const Uint64 draws = calls[static_cast<Uint32>(CallClass::Draws)];
const Uint64 accessorCalls = calls[static_cast<Uint32>(CallClass::AccessorCalls)];
String line = "MGPipe stats:";
line += " frames=" + std::to_string(frames);
line += " window=" + std::to_string(windowFrames);
line += " draws=" + std::to_string(draws);
line += " draws/f=" + FormatFixed2(draws, windowFrames);
line += " acc=" + std::to_string(accessorCalls);
// Same rule as the per-frame fields: a window with no draw in it has no per-draw
// number, and "0.00" next to a non-zero acc= is the same lie in a smaller font.
line += " acc/draw=" + FormatFixed2(accessorCalls, draws);
// "bytes/f[...]" only when there IS a frame to divide by; otherwise the bracket is
// labelled "bytes[...]" and carries the window totals verbatim.
line += perFrame ? " bytes/f[" : " bytes[";
for (Uint32 i = 0; i < kByteClassCount; ++i) {
if (i != 0) {
line += " ";
}
line += kByteClassShort[i];
line += "=";
line += perFrame ? FormatFixed2(bytes[i], windowFrames) : std::to_string(bytes[i]);
}
line += "] tex[emit=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadEmissions)]);
line += " box=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadBoxEmissions)]);
line += " rect=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadRectEmissions)]);
line += " jobs=" + std::to_string(calls[static_cast<Uint32>(CallClass::TextureUploadJobs)]);
line += "] gates[";
for (Uint32 i = 0; i < kGateCount; ++i) {
if (i != 0) {
line += " ";
}
line += kGateShort[i];
line += "=";
line += std::to_string(gateHit[i]);
line += "/";
line += std::to_string(gateMiss[i]);
}
line += "]";
return line;
}
void AdvanceSummaryWindow() {
for (Uint32 i = 0; i < kByteClassCount; ++i) {
g_windowBaseBytes[i] = Read(g_totalBytes[i]);
}
for (Uint32 i = 0; i < kCallClassCount; ++i) {
g_windowBaseCalls[i] = Read(g_totalCalls[i]);
}
for (Uint32 i = 0; i < kGateCount; ++i) {
g_windowBaseGateHit[i] = Read(g_totalGateHit[i]);
g_windowBaseGateMiss[i] = Read(g_totalGateMiss[i]);
}
g_windowBaseFrames = Read(g_frameCount);
}
String FormatJson() {
String json = "{\n";
json += " \"frames\": " + std::to_string(Read(g_frameCount)) + ",\n";
json += " \"bytes\": {\n";
for (Uint32 i = 0; i < kByteClassCount; ++i) {
json += " \"";
json += kByteClassNames[i];
json += "\": " + std::to_string(Read(g_totalBytes[i]));
json += (i + 1 == kByteClassCount) ? "\n" : ",\n";
}
json += " },\n \"calls\": {\n";
for (Uint32 i = 0; i < kCallClassCount; ++i) {
json += " \"";
json += kCallClassNames[i];
json += "\": " + std::to_string(Read(g_totalCalls[i]));
json += (i + 1 == kCallClassCount) ? "\n" : ",\n";
}
json += " },\n \"gates\": {\n";
for (Uint32 i = 0; i < kGateCount; ++i) {
json += " \"";
json += kGateNames[i];
json += "\": {\"hit\": " + std::to_string(Read(g_totalGateHit[i])) +
", \"miss\": " + std::to_string(Read(g_totalGateMiss[i])) + "}";
json += (i + 1 == kGateCount) ? "\n" : ",\n";
}
json += " },\n \"cmd-bytes-per-draw-histogram\": [";
for (Uint32 i = 0; i < kPayloadHistogramBuckets; ++i) {
if (i != 0) {
json += ", ";
}
json += std::to_string(Read(g_totalPayloadBuckets[i]));
}
json += "]\n}\n";
return json;
}
void SetEnabledForTesting(Bool enabled) { g_pipeStatsEnabled = enabled; }
void ResetForTesting() { ResetCounters(); }
} // namespace MobileGL::MG_Util::PipeStats
+192
View File
@@ -0,0 +1,192 @@
// MobileGL - MobileGL/MG_Util/Metrics/PipeStats.h
// Copyright (c) 2025-2026 MobileGL-Dev
// Licensed under the GNU Lesser General Public License v3.0:
// https://www.gnu.org/licenses/gpl-3.0.txt
// https://www.gnu.org/licenses/lgpl-3.0.txt
// SPDX-License-Identifier: LGPL-3.0-only
// End of Source File Header
#pragma once
#include <Includes.h>
// MGPipe boundary counters (plan B section 11 "P0 - hygiene, measurement, gates and
// skeleton", and the corollary in section 2.3.1).
//
// WHAT THIS IS FOR. The disaggregation plan has to size two things it cannot size by
// reading the tree: how many BYTES cross the frontend/backend boundary per frame (that
// sizes SEG_STAGE and the command segment), and how many accessor CALLS and memo-gate
// probes the backends actually execute per draw (that decides whether pushing state is
// cheaper than pulling it at all). Section 2.3.1 makes the second one the load-bearing
// number: the static call-site counts everyone quoted - Espryt 124 / Magma 169 - are NOT
// the dynamic per-draw cost, because every one of those paths is memo-gated, and the real
// steady state is believed to be 10-25 accessor calls per backend per draw. Without a
// dynamic counter the P2 verdict stays a guess.
//
// COST WHEN OFF. g_pipeStatsEnabled is a plain global Bool latched once at Init() from
// MG_Config::Features.PipeStats (MOBILEGL_PIPE_STATS). Every counting site in the two
// backends is written as
//
// if (MG_Util::PipeStats::Enabled()) MG_Util::PipeStats::Add...(...);
//
// so with the feature off a site costs one load of a hot global plus one never-taken,
// perfectly-predicted branch, and none of the counter state is touched. The counters
// themselves are relaxed atomics rather than plain integers because texture and buffer
// staging can be reached from more than one thread; relaxed adds cost nothing extra on the
// off path, which never reaches them. The off-path cost is not a guess: see the paired
// A/B in the branch's evidence.
//
// WHAT IS COUNTED AND WHAT IS NOT: see the site inventory in PipeStats.cpp. That inventory
// is the contract - it names every path that is NOT wired, because a byte class that reads
// zero while a real copy runs uncounted is worse than a missing counter.
namespace MobileGL::MG_Util::PipeStats {
// Byte classes. Every one of these names a population of bytes that would have to be
// MOVED across the boundary once the backend no longer shares an address space with
// the frontend, which is why they are grouped this way rather than by call site.
enum class ByteClass : Uint32 {
// Buffer object contents flushed to the driver: glBufferData / glBufferSubData /
// map-write ranges / the persistent upload ring (Espryt), and every host->device
// copy of a buffer object's contents (Magma).
StageBuffer = 0,
// Texel bytes handed to glTexSubImage & friends / packed into the Vulkan upload
// staging slice, whichever upload shape was chosen.
StageTexture,
// The default-uniform-block ("global UBO") image, uploaded at most once per program
// per frame.
StageUboGlobal,
// Named uniform-block bytes that a backend has to repack itself, i.e. Magma's UBO
// ring. Espryt binds the frontend buffer straight to the driver and contributes
// nothing here - which is exactly the asymmetry D-B8 is about.
StageUboNamed,
// Client-memory vertex arrays uploaded into a scratch VBO / transient arena slice on
// the draw path.
StageVertexClient,
// Client-memory / rewritten index data staged on the draw path.
StageIndexClient,
// Draw-parameter bytes a backend synthesises and stages for the draw itself: the
// indirect-command array and the compute path's per-draw info array. These are the
// bytes that become MGPipe command-record payload once the boundary is explicit,
// which is why they are not folded into the index class.
StageIndirectCmd,
// Bytes pushed because a persistently mapped range was published to the backend.
PersistentMapPush,
// PLACEHOLDER (plan section 6.3): the residual value block does not exist yet. The
// class is minted now so the counter names never churn; it stays at 0 until P2.
ResidualValueBlock,
Count
};
// Call classes: the dynamic per-draw cost section 2.3.1 says P2 cannot be decided
// without.
enum class CallClass : Uint32 {
// Draws that reached an instrumented backend draw-preparation entry point. The
// denominator for every "per draw" number below.
Draws = 0,
// GLContext accessor calls actually EXECUTED on the instrumented paths. Counted in
// static tallies at the ~10 hot entry points, not by wrapping all 293 call sites -
// see the inventory in PipeStats.cpp for exactly what is and is not in this number.
AccessorCalls,
// Texture upload emissions: one per (upload target, level) that actually shipped
// texels. The eventual resource_subdata record count.
TextureUploadEmissions,
// Emissions that took the union-box shape (one driver upload job).
TextureUploadBoxEmissions,
// Emissions that took the refined rect-list shape (N driver upload jobs). The
// box/rect split is the thing SSIM cannot see and the +6 ms/frame Mali cliff came
// from, so it is counted separately from the byte total.
TextureUploadRectEmissions,
// Driver upload jobs issued by those emissions: 1 per box emission, N per rect-list
// emission.
TextureUploadJobs,
Count
};
// Memo gates. Each is a place where a backend decides "nothing moved, skip the work".
// Hit == the gate short-circuited; Miss == it fell through and did the work. The six
// are exactly the ones section 2.3.1 tabulates.
enum class Gate : Uint32 {
// DirectGLES.cpp SyncRenderState: the render-state-version early-out.
EsprytRenderState = 0,
// DirectGLES.cpp SyncNeccessaryTextures: the six-value sync-list key compare.
EsprytTextureSyncList,
// DirectGLES.cpp CurrentUnitBindingsEpoch: the (context, max unit, bind generation)
// shutter over the unit walk.
EsprytUnitBindingsEpoch,
// VulkanRenderer.cpp TrySetupDrawFastPath: the whole snapshot fast path.
MagmaDrawFastPath,
// VulkanRenderer.cpp GetOrCreatePipeline: the pipeline memo.
MagmaPipelineMemo,
// VulkanRenderer.cpp ApplyDynamicDrawStateTail: the version+extent tail gate.
MagmaDynamicTail,
Count
};
// Per-draw command payload size histogram (plan section 4.5.7: SEG_CMD has to be sized
// off the DISTRIBUTION, not off a per-frame total). PLACEHOLDER in P0: MGPipe emits no
// records yet, so nothing in the backends calls RecordDrawPayloadBytes. The bucketing
// and the reporting are implemented and unit-tested so that the first generator to
// emit records only has to add the one call.
inline constexpr Uint32 kPayloadHistogramBuckets = 24;
// Frames between two summary lines when MOBILEGL_PIPE_STATS=1.
inline constexpr Uint64 kDefaultSummaryFramePeriod = 120;
// The period Init() latched from MOBILEGL_PIPE_STATS_PERIOD (kDefaultSummaryFramePeriod
// when unset); never 0.
Uint64 SummaryFramePeriod();
// The latch. Read directly by Enabled() so the off path is a global load and a
// predicted branch - do not turn this into a function call.
extern Bool g_pipeStatsEnabled;
inline Bool Enabled() { return g_pipeStatsEnabled; }
// Latches g_pipeStatsEnabled from MG_Config::Features.PipeStats and clears every
// counter. Called from MobileGL::Initialize() right after the config load.
void Init();
// Final summary line plus, if MOBILEGL_PIPE_STATS_FILE names a path, the JSON dump.
// Called from MobileGL's teardown. Idempotent.
void Shutdown();
void AddBytes(ByteClass byteClass, Uint64 bytes);
void AddCalls(CallClass callClass, Uint64 count);
void CountGate(Gate gate, Bool hit);
void RecordDrawPayloadBytes(Uint64 bytes);
// Frame boundary: publishes the frame's values to Tracy (when TRACY_ENABLE), folds them
// into the run totals, clears the frame accumulators, and every kSummaryFramePeriod
// frames emits the summary line. Called from each backend's Present().
void OnPresent();
// --- introspection, for the unit test and the JSON dump -------------------------
Uint64 FrameBytes(ByteClass byteClass);
Uint64 TotalBytes(ByteClass byteClass);
Uint64 FrameCalls(CallClass callClass);
Uint64 TotalCalls(CallClass callClass);
Uint64 TotalGateHits(Gate gate);
Uint64 TotalGateMisses(Gate gate);
Uint64 TotalPayloadBucket(Uint32 bucket);
Uint64 FrameCount();
const char* NameOf(ByteClass byteClass);
const char* NameOf(CallClass callClass);
const char* NameOf(Gate gate);
// The compact fixed-format one-liner MGLOG_I prints, covering the CURRENT window (see
// AdvanceSummaryWindow). PURE: calling it twice returns the same text and changes no
// counter, so a probe, a test or a second reporting channel can format the window
// without stealing it from the log.
String FormatWindowLine();
// Closes the current window: the run totals as of now become the base the next
// FormatWindowLine() subtracts. Emitting the line and advancing the window are separate
// on purpose - the pair used to be one function whose name promised a formatter.
void AdvanceSummaryWindow();
// The teardown dump. Run totals only: a per-frame JSON stream is a different tool.
String FormatJson();
// Test hooks, used by no shipping path. Init() clears the counters through an internal
// ResetCounters() rather than by calling ResetForTesting().
void SetEnabledForTesting(Bool enabled);
void ResetForTesting();
} // namespace MobileGL::MG_Util::PipeStats
@@ -84,8 +84,18 @@ namespace MobileGL {
struct BlockPlan {
Instruction* structType = nullptr;
uint32_t storageClass = 0;
// A bounded block's length in words. For an open-ended block - one whose
// last member is a runtime array - the FIXED PREFIX in words, i.e. the
// runtime array's own offset, which is where its element 0 starts.
uint32_t wordCount = 0;
bool openEnded = false;
// The original runtime array's stride in words; what one element of it
// steps by, and what its word count divides by to become a length.
uint32_t tailStrideWords = 0;
std::vector<ChainPlan> chains;
// The OpArrayLength users of an open-ended block's variables, which count
// WORDS once the member is a `uint[]` and so have to be rewritten too.
std::vector<Instruction*> arrayLengths;
};
bool IsDoubleType(const Instruction* type) {
@@ -178,8 +188,9 @@ namespace MobileGL {
}
// Byte size of a type as it is laid out INSIDE a block, or 0 when this pass
// cannot describe it (a runtime array, a width it does not carry, a matrix with
// no stride or a row-major one).
// cannot describe it (a runtime array - the one place a block may have one is
// its last member, which MeasureBlock handles above this - a width it does not
// carry, a matrix with no stride or a row-major one).
uint32_t LaidOutByteSize(IRContext* context, const TypeCursor& cursor) {
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return 0;
@@ -231,8 +242,17 @@ namespace MobileGL {
}
// Whether this type decomposes into scalars the rewrite can move one word at a
// time, counting them so a whole-aggregate access can be refused before it is
// expanded.
// time. |leafCount| counts them, so a whole-aggregate access can be refused
// before it is expanded; passing NULL asks the SHAPE question alone - is this
// type addressable at all - and then identical array elements and vector
// components are walked once instead of once each, because the answer cannot
// differ between them and the walk of a big one would not be free.
//
// The two questions are separate because only a LOAD or a STORE expands into
// leaves, and the cap bounds one of those. How large a runtime array's element
// is says nothing about how many scalars a single access to it moves, so
// MeasureBlock asks for the shape and BuildPlans applies the cap where it
// belongs - per chain, to the type that chain actually names.
bool CanDecompose(IRContext* context, const TypeCursor& cursor, uint32_t* leafCount) {
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return false;
@@ -240,12 +260,15 @@ namespace MobileGL {
case spv::Op::OpTypeInt:
case spv::Op::OpTypeFloat:
if (ScalarByteSize(type) == 0) return false;
if (leafCount == nullptr) return true;
++*leafCount;
return *leafCount <= kMaxLeavesPerAccess;
case spv::Op::OpTypeVector: {
TypeCursor component;
component.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < type->GetSingleWordInOperand(1); ++i) {
const uint32_t repeats =
leafCount == nullptr ? 1u : type->GetSingleWordInOperand(1);
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, component, leafCount)) return false;
}
return true;
@@ -257,7 +280,9 @@ namespace MobileGL {
}
TypeCursor column;
column.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < type->GetSingleWordInOperand(1); ++i) {
const uint32_t repeats =
leafCount == nullptr ? 1u : type->GetSingleWordInOperand(1);
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, column, leafCount)) return false;
}
return true;
@@ -273,10 +298,12 @@ namespace MobileGL {
context->get_constant_mgr()->FindDeclaredConstant(type->GetSingleWordInOperand(1));
if (length == nullptr || length->AsIntConstant() == nullptr) return false;
const uint32_t count = length->AsIntConstant()->GetU32BitValue();
if (count == 0 || count > kMaxLeavesPerAccess) return false;
if (count == 0) return false;
if (leafCount != nullptr && count > kMaxLeavesPerAccess) return false;
TypeCursor element = cursor;
element.typeId = type->GetSingleWordInOperand(0);
for (uint32_t i = 0; i < count; ++i) {
const uint32_t repeats = leafCount == nullptr ? 1u : count;
for (uint32_t i = 0; i < repeats; ++i) {
if (!CanDecompose(context, element, leafCount)) return false;
}
return true;
@@ -300,6 +327,63 @@ namespace MobileGL {
}
}
// Measures the block struct itself. A bounded block reports its laid-out byte
// size; a block whose LAST member is a runtime array - the only place GLSL lets
// one stand, and the only place SPIR-V lets a Block have one - reports the byte
// offset that array starts at and says so through |openEnded|, with the array's
// stride alongside. A runtime array anywhere else, one without a stride the
// words can step by, or one whose element the rewrite could not take apart is a
// shape this pass does not describe, and so is a bounded block it cannot size.
bool MeasureBlock(IRContext* context, const Instruction* structType, uint32_t* bytes,
bool* openEnded, uint32_t* tailStrideBytes) {
*bytes = 0;
*openEnded = false;
*tailStrideBytes = 0;
const uint32_t structId = structType->result_id();
const uint32_t memberCount = structType->NumInOperands();
uint64_t end = 0;
for (uint32_t member = 0; member < memberCount; ++member) {
uint32_t offset = 0;
if (!TryGetMemberDecorationLiteral(context, structId, member, spv::Decoration::Offset,
&offset)) {
return false;
}
const TypeCursor cursor = MemberCursor(context, structType, member);
const Instruction* type = context->get_def_use_mgr()->GetDef(cursor.typeId);
if (type == nullptr) return false;
if (type->opcode() == spv::Op::OpTypeRuntimeArray) {
if (member + 1 != memberCount) return false;
uint32_t stride = 0;
if (!TryGetDecorationLiteral(context, cursor.typeId, spv::Decoration::ArrayStride,
&stride) ||
stride == 0 || stride % kWordBytes != 0) {
return false;
}
// The member's own matrix decorations describe the array's ELEMENTS,
// exactly as they do for a bounded array of matrices. Only the shape
// is asked for: how big one element is decides nothing about how
// many scalars one access moves, and a leaf cap here would decline a
// block over a member the shader may never read whole.
TypeCursor element = cursor;
element.typeId = type->GetSingleWordInOperand(0);
if (!CanDecompose(context, element, nullptr)) return false;
// Element 0 has to start past every fixed member, or the words the
// prefix owns and the words the array owns would overlap.
if (offset < end) return false;
end = offset;
*openEnded = true;
*tailStrideBytes = stride;
break;
}
const uint32_t size = LaidOutByteSize(context, cursor);
if (size == 0) return false;
end = std::max<uint64_t>(end, static_cast<uint64_t>(offset) + size);
}
if (end > kMaxBlockBytes) return false;
*bytes = static_cast<uint32_t>(end);
return true;
}
bool TypeContainsFloat64(IRContext* context, uint32_t typeId,
std::unordered_set<uint32_t>& visiting) {
const Instruction* type = context->get_def_use_mgr()->GetDef(typeId);
@@ -366,6 +450,9 @@ namespace MobileGL {
switch (type->opcode()) {
case spv::Op::OpTypeArray:
// A runtime array steps exactly like a bounded one; only its end is
// unknown, and a chain never needs that.
case spv::Op::OpTypeRuntimeArray:
if (!TryGetDecorationLiteral(context, cursor.typeId, spv::Decoration::ArrayStride,
&stride)) {
return false;
@@ -611,6 +698,39 @@ namespace MobileGL {
}
}
// Replaces an OpArrayLength of an open-ended block with the element count
// of the ORIGINAL runtime array. The instruction now counts the words of
// the flattened `uint[]`, so the length is `(words - prefix) / stride`, in
// unsigned arithmetic and clamped at zero when the bound range does not
// even reach the array's offset - a wrapped subtraction would otherwise
// report a few billion elements. The division floors, which is what GL
// defines `.length()` as for a range that is not a whole number of
// elements. A fresh OpArrayLength is issued rather than the old one re-aimed,
// so the uses being redirected are never the ones the arithmetic just made.
void RewriteArrayLength(Instruction* arrayLength, uint32_t prefixWords, uint32_t strideWords) {
InstructionBuilder builder(m_context, arrayLength, kPreservedAnalyses);
const uint32_t variableId = arrayLength->GetSingleWordInOperand(0);
const uint32_t wordsId = m_context->TakeNextId();
builder.AddInstruction(MakeUnique<Instruction>(
m_context, spv::Op::OpArrayLength, m_uintTypeId, wordsId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {variableId}},
{SPV_OPERAND_TYPE_LITERAL_INTEGER, {0u}}}));
uint32_t count = wordsId;
if (prefixWords != 0) {
const uint32_t prefixId = UintConstant(prefixWords);
const uint32_t past = Binary(builder, spv::Op::OpISub, m_uintTypeId, count, prefixId);
const uint32_t tooShort =
Binary(builder, spv::Op::OpULessThan, m_boolTypeId, count, prefixId);
count = Select(builder, tooShort, UintConstant(0), past);
}
if (strideWords != 1) {
count = Binary(builder, spv::Op::OpUDiv, m_uintTypeId, count,
UintConstant(strideWords));
}
m_context->ReplaceAllUsesWith(arrayLength->result_id(), count);
m_context->KillInst(arrayLength);
}
private:
uint32_t ComponentWords(uint32_t componentTypeId) {
return ScalarByteSize(m_context->get_def_use_mgr()->GetDef(componentTypeId)) /
@@ -788,38 +908,72 @@ namespace MobileGL {
return false;
}
// A fresh `uint[length]` with ArrayStride 4, spliced in immediately BEFORE the
// block that will name it - SPIR-V has no forward references between types, so
// appending it at the end of the section would make the module invalid. A
// duplicate OpTypeArray is legal (SPIR-V 2.8 exempts aggregates from the
// uniqueness rule, and so does spirv-val), so no search for an existing one is
// needed; the LENGTH CONSTANT is not exempt, and if the module already declares
// it after the block there is nowhere legal to put the array - the block is then
// declined and keeps today's behaviour. Returns 0 for that, and for a uint type
// that is itself declared too late.
// A fresh `uint[length]` with ArrayStride 4 - or, for an open-ended block, a
// `uint[]` runtime array with the same stride and no length at all - spliced in
// immediately BEFORE the block that will name it: SPIR-V has no forward
// references between types, so appending it at the end of the section would make
// the module invalid. A duplicate OpTypeArray or OpTypeRuntimeArray is legal
// (SPIR-V 2.8 exempts aggregates from the uniqueness rule, and so does
// spirv-val), so no search for an existing one is needed; the LENGTH CONSTANT is
// not exempt, and if the module already declares it after the block there is
// nowhere legal to put the array - the block is then declined and keeps today's
// behaviour. Returns 0 for that; an open-ended block has no length constant to
// place, so that reason cannot reach it.
//
// The `uint` element type is a different matter, and only for an OPEN-ENDED
// block. The front end declares types in first-use order, so a block that is the
// first thing a shader touches sits BEFORE the module's `uint` (or the module has
// none, and the one the pass asked for was appended at the end). Declining there
// would send exactly the buffers this rewrite exists for back to the demotion on
// nothing but where they stand in the source. OpTypeInt has no operands, and
// nothing that names it can precede where it was, so moving it up in front of the
// block is always legal. A BOUNDED block keeps declining instead: that is what it
// has always done, and widening it is a change to a path this one does not need.
//
// NOTHING IS WRITTEN until every reason to decline has been ruled out, so a block
// this returns 0 for leaves the module as it found it - which is what lets
// Process() truthfully report SuccessWithoutChange for a module of only those.
uint32_t CreateWordArrayTypeBefore(IRContext* context, Instruction* structType,
uint32_t uintTypeId, uint32_t length) {
if (!DeclaredBefore(context, uintTypeId, structType->result_id())) return 0;
uint32_t uintTypeId, uint32_t length, bool openEnded) {
Instruction* uintType = context->get_def_use_mgr()->GetDef(uintTypeId);
if (uintType == nullptr || uintType->opcode() != spv::Op::OpTypeInt) return 0;
const bool hoistUint = !DeclaredBefore(context, uintTypeId, structType->result_id());
if (hoistUint && !openEnded) return 0;
uint32_t lengthConstantId = 0;
if (!openEnded) {
auto* constantMgr = context->get_constant_mgr();
const spvtools::opt::analysis::Type* uintType = context->get_type_mgr()->GetType(uintTypeId);
if (uintType == nullptr) return 0;
const spvtools::opt::analysis::Type* uintDescriptor =
context->get_type_mgr()->GetType(uintTypeId);
if (uintDescriptor == nullptr) return 0;
const spvtools::opt::analysis::Constant* lengthConstant =
constantMgr->GetConstant(uintType, {length});
constantMgr->GetConstant(uintDescriptor, {length});
if (lengthConstant == nullptr) return 0;
Module::inst_iterator position = PositionOf(context, structType);
if (position == context->types_values_end()) return 0;
Instruction* lengthInst = constantMgr->GetDefiningInstruction(lengthConstant, 0, &position);
// Created in front of the block when it is not there yet, so the only way
// this declines is a constant the module already declares after it.
Instruction* lengthInst =
constantMgr->GetDefiningInstruction(lengthConstant, 0, &position);
if (lengthInst == nullptr) return 0;
if (!DeclaredBefore(context, lengthInst->result_id(), structType->result_id())) return 0;
lengthConstantId = lengthInst->result_id();
}
const uint32_t arrayTypeId = context->TakeNextId();
if (arrayTypeId == 0) return 0;
auto arrayType = MakeUnique<Instruction>(
if (hoistUint) uintType->InsertBefore(structType);
std::unique_ptr<Instruction> arrayType =
openEnded
? MakeUnique<Instruction>(
context, spv::Op::OpTypeRuntimeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}}})
: MakeUnique<Instruction>(
context, spv::Op::OpTypeArray, 0, arrayTypeId,
std::initializer_list<Operand>{{SPV_OPERAND_TYPE_ID, {uintTypeId}},
{SPV_OPERAND_TYPE_ID, {lengthInst->result_id()}}});
{SPV_OPERAND_TYPE_ID, {lengthConstantId}}});
Instruction* inserted = structType->InsertBefore(std::move(arrayType));
context->AnalyzeDefUse(inserted);
context->get_decoration_mgr()->AddDecorationVal(
@@ -948,8 +1102,14 @@ namespace MobileGL {
Instruction* structType = defUseMgr->GetDef(structId);
TypeCursor blockCursor;
blockCursor.typeId = structId;
const uint32_t blockBytes = LaidOutByteSize(context, blockCursor);
if (blockBytes == 0 || blockBytes % kWordBytes != 0) {
uint32_t blockBytes = 0;
bool openEnded = false;
uint32_t tailStrideBytes = 0;
// An open-ended block whose runtime array is its only member measures a
// prefix of 0 bytes and is perfectly describable; only a BOUNDED block of
// no bytes is not, and MeasureBlock already refuses to size one of those.
if (!MeasureBlock(context, structType, &blockBytes, &openEnded, &tailStrideBytes) ||
blockBytes % kWordBytes != 0 || (!openEnded && blockBytes == 0)) {
MGLOG_D("[spirv] storage block %%%u holds a double but its byte layout cannot be "
"described exactly; left to the fp64 demotion",
structId);
@@ -960,11 +1120,15 @@ namespace MobileGL {
plan.structType = structType;
plan.storageClass = storageClassByStruct[structId];
plan.wordCount = blockBytes / kWordBytes;
plan.openEnded = openEnded;
plan.tailStrideWords = tailStrideBytes / kWordBytes;
const uint32_t lastMember = structType->NumInOperands() - 1;
bool expressible = true;
for (Instruction* variable : variablesByStruct[structId]) {
std::vector<Instruction*> chains;
std::unordered_set<uint32_t> seenChains;
std::unordered_set<uint32_t> seenLengths;
defUseMgr->ForEachUser(variable, [&](Instruction* user) {
if (!expressible) return;
switch (user->opcode()) {
@@ -982,6 +1146,27 @@ namespace MobileGL {
}
expressible = false;
return;
case spv::Op::OpArrayLength: {
// Only an open-ended block has a length to ask for, and
// only of its last member; the result has to be the 32-bit
// uint the rewrite's arithmetic is typed in, which is the
// only result type the instruction allows anyway.
const Instruction* resultType = defUseMgr->GetDef(user->type_id());
const bool isUint = resultType != nullptr &&
resultType->opcode() == spv::Op::OpTypeInt &&
resultType->GetSingleWordInOperand(0) == 32u &&
resultType->GetSingleWordInOperand(1) == 0u;
if (openEnded && isUint && user->NumInOperands() >= 2 &&
user->GetSingleWordInOperand(0) == variable->result_id() &&
user->GetSingleWordInOperand(1) == lastMember) {
if (seenLengths.insert(user->result_id()).second) {
plan.arrayLengths.push_back(user);
}
return;
}
expressible = false;
return;
}
default:
expressible = false;
return;
@@ -1058,16 +1243,25 @@ namespace MobileGL {
Emitter emitter(irContext, uintTypeId, boolTypeId, floatTypeId);
bool modified = false;
// Every block declines before anything is written for it, so |touched| only ever
// parts company with |modified| on a shape that cannot happen without the module
// running out of ids - and even then the status must not claim the bytes are
// untouched, because the caller relies on that to skip invalidating its analyses.
bool touched = false;
for (BlockPlan& plan : plans) {
const uint32_t structId = plan.structType->result_id();
const uint32_t arrayTypeId =
CreateWordArrayTypeBefore(irContext, plan.structType, uintTypeId, plan.wordCount);
const uint32_t arrayTypeId = CreateWordArrayTypeBefore(
irContext, plan.structType, uintTypeId, plan.wordCount, plan.openEnded);
if (arrayTypeId == 0) {
// Nothing was written for it, so the module is still the one that came in.
MGLOG_D("[spirv] storage block %%%u: no legal place for the flattened word array; "
"left to the fp64 demotion",
structId);
continue;
}
// Past this point the module HAS been written to, so an abandoned block would
// leave a dead type behind - the status has to say so even then.
touched = true;
const uint32_t wordPointerTypeId = irContext->get_type_mgr()->FindPointerToType(
uintTypeId, static_cast<spv::StorageClass>(plan.storageClass));
if (wordPointerTypeId == 0) continue;
@@ -1095,6 +1289,9 @@ namespace MobileGL {
}
irContext->KillInst(chainPlan.chain);
}
for (Instruction* arrayLength : plan.arrayLengths) {
emitter.RewriteArrayLength(arrayLength, plan.wordCount, plan.tailStrideWords);
}
const std::vector<spv::Decoration> surviving = SurvivingAccessQualifiers(
irContext, structId, plan.structType->NumInOperands());
@@ -1113,12 +1310,19 @@ namespace MobileGL {
{SPV_OPERAND_TYPE_DECORATION, {static_cast<uint32_t>(kind)}}});
}
modified = true;
MGLOG_D("[spirv] storage block %%%u: flattened into %u words so its 64-bit members keep "
"the byte layout the application bound",
if (plan.openEnded) {
MGLOG_D("[spirv] storage block %%%u: flattened into an open-ended word array (%u-word "
"prefix, %u-word elements) so its 64-bit members keep the byte layout the "
"application bound",
structId, plan.wordCount, plan.tailStrideWords);
} else {
MGLOG_D("[spirv] storage block %%%u: flattened into %u words so its 64-bit members "
"keep the byte layout the application bound",
structId, plan.wordCount);
}
}
if (!modified) {
if (!modified && !touched) {
return Status::SuccessWithoutChange;
}
irContext->InvalidateAnalysesExceptFor(IRContext::kAnalysisNone);
@@ -66,17 +66,32 @@ namespace MobileGL {
// fp32 promise DemoteFloat64Pass already makes - what changes is only that the
// BYTES around the value stay where the application put them.
//
// AN OPEN-ENDED BLOCK - one whose last member is a runtime array, the
// `buffer B { double data[]; }` every unsized storage buffer is spelled as - is
// flattened the same way: the members before the array are the fixed prefix, and
// the flattened member is itself a `uint[]` runtime array, ArrayStride 4, with no
// length for the driver to re-derive. Element i of the original array lives at
// word `prefix + i * stride` of it, which is where the application put it. The
// block's `.length()` is rewritten too, because OpArrayLength on the flattened
// member counts WORDS: it becomes `(words - prefix) / stride` in unsigned
// arithmetic, clamped at zero when the bound range is shorter than the prefix,
// which is the floor GL defines `.length()` as.
//
// DECLINES, leaving the block exactly as it was for DemoteFloat64Pass to handle the
// old way, whenever it meets something it cannot rewrite exactly:
// - a block whose variable is used as anything but an access-chain base (loaded
// whole, handed to a function, asked its OpArrayLength);
// whole, handed to a function), or asked an OpArrayLength it is not open-ended
// for;
// - an access chain that is not rooted at the variable, or whose result feeds
// anything but a plain OpLoad / OpStore (an atomic, OpCopyMemory, a further
// chain);
// - a non-constant index into a struct, a runtime array anywhere in the block, a
// RowMajor matrix (its columns are not contiguous, so a whole-column access is
// not one range), a member width other than 32 or 64 bits, or an offset or
// stride that is not a multiple of 4;
// chain), or one that names a whole runtime array rather than an element of it;
// - a non-constant index into a struct, a runtime array that is not the last
// member of the block itself (nested in a member, or followed by another -
// shapes GLSL cannot spell but SPIR-V can), a runtime array without an
// ArrayStride or whose element the pass cannot decompose, a RowMajor matrix (its
// columns are not contiguous, so a whole-column access is not one range), a
// member width other than 32 or 64 bits, or an offset or stride that is not a
// multiple of 4;
// - a load or store whose type decomposes into more scalars than the cap below,
// so legalizing a block can never explode the module.
//
@@ -234,6 +234,10 @@ target_link_libraries(glretrace_common PUBLIC retrace_common glhelpers glproc)
add_library(trace_replay_runner SHARED
trace_replay_core.cpp
trace_replay_jni.cpp
# P0 spike A. Android-only, and deliberately its own TU: trace_replay_core.cpp is
# shared verbatim with the desktop mobilegl_trace_replay runner
# (tools/trace_replay/CMakeLists.txt), which cannot see <android/log.h>.
spawn_spike.cpp
"${CMAKE_CURRENT_LIST_DIR}/../../../../../tools/trace_replay/apitrace_fbo_dump.cpp")
target_compile_features(trace_replay_runner PRIVATE cxx_std_17)
@@ -249,4 +253,5 @@ target_link_libraries(trace_replay_runner
retrace_common
image
android
log
dl)
@@ -0,0 +1,227 @@
// P0 spike A - the Android half of the delivery chain (PLAN-B.md §8.1, inheriting
// PLAN.md §11.1-§11.6). See spawn_spike.hpp for what the spike is asking.
//
// Android-only on purpose: this TU is listed only by
// android-plugin/app/src/trace/cpp/CMakeLists.txt. Its sibling trace_replay_core.cpp is
// shared with the DESKTOP mobilegl_trace_replay runner, which has no <android/log.h>,
// so nothing Android-specific may live there.
#include "spawn_spike.hpp"
#if !defined(__ANDROID__)
#error "spawn_spike.cpp is Android-only; do not add it to the desktop trace replay build"
#endif
#include <android/log.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string>
// execve needs the environment the parent already has: a server process started from
// the app must inherit it, and handing it an empty one would change what is being tested.
extern "C" char** environ;
namespace mobilegl_trace {
namespace {
constexpr const char* kSpikeLogTag = "MobileGLTraceRunner";
std::string ReadWholeFile(const std::string& path) {
std::ifstream input(path, std::ios::binary);
if (!input) {
return {};
}
std::ostringstream contents;
contents << input.rdbuf();
std::string text = contents.str();
while (!text.empty() && (text.back() == '\n' || text.back() == '\r' || text.back() == '\0')) {
text.pop_back();
}
return text;
}
// The domain this process is in. `u:r:untrusted_app:s0:...` is the whole point of the
// spike: an exec that works from an `adb run-as` shell says nothing about whether the
// app itself is allowed to do it, because that shell is a different SELinux domain.
std::string ReadSelfSelinuxContext() {
const std::string context = ReadWholeFile("/proc/self/attr/current");
return context.empty() ? "<unreadable>" : context;
}
// Starts the child with its stdout and stderr redirected into `outputPath`, reports the
// child pid through `childPid` and, when the exec itself was refused, the child's errno
// through `execErrno`. Returns 0, or the errno of a failure that happened before the
// child existed at all.
//
// fork/execve, not posix_spawn: bionic only declares posix_spawn from API 28 while
// MobileGL ships at minSdk 26 (the root CMakeLists.txt pins MOBILEGL_ANDROID_API_LEVEL
// to 26 and refuses to configure lower), so posix_spawn is not available to the shipping
// build and this is the shape the production spawn path has to take. Nothing happens
// between fork and execve except open/dup2/execve/write/_exit, all async-signal-safe,
// because the parent is a multi-threaded JVM process.
int SpawnSpikeChild(const std::string& serverPath,
const std::string& markerPath,
const std::string& outputPath,
pid_t* childPid,
int* execErrno) {
*execErrno = 0;
char* argv[] = {const_cast<char*>(serverPath.c_str()),
const_cast<char*>(markerPath.c_str()), nullptr};
// The errno of a refused exec is the answer this spike is here to bring back, and it
// is raised in a process that cannot return anything: by the time the parent sees a
// wait status the reason has been flattened into an exit code. So the child writes
// the raw errno into a close-on-exec pipe. A successful exec closes the write end for
// free and the parent reads EOF; a refused one leaves the four bytes behind. EACCES
// (SELinux, or a noexec mount) and ENOEXEC (a mangled or non-PIE file) are entirely
// different verdicts for the design and this is the only thing that separates them.
int report[2] = {-1, -1};
if (pipe2(report, O_CLOEXEC) != 0) {
return errno;
}
const pid_t forked = fork();
if (forked < 0) {
const int forkErrno = errno;
close(report[0]);
close(report[1]);
return forkErrno;
}
if (forked == 0) {
close(report[0]);
// Without this the child's output is unobservable: an Android app process has
// stdout on /dev/null, so a printed line would vanish and the spike could not
// tell "ran and printed" apart from "never ran".
const int outputFd = open(outputPath.c_str(), O_CREAT | O_WRONLY | O_TRUNC, 0664);
if (outputFd >= 0) {
dup2(outputFd, STDOUT_FILENO);
dup2(outputFd, STDERR_FILENO);
if (outputFd != STDOUT_FILENO && outputFd != STDERR_FILENO) {
close(outputFd);
}
}
execve(serverPath.c_str(), argv, environ);
const int failure = errno;
// Only reached when the exec was refused - the one outcome this spike is about.
const ssize_t written = write(report[1], &failure, sizeof(failure));
static_cast<void>(written);
// 127 is the shell's convention for "could not exec" and is distinguishable from
// every status the stub itself can return.
_exit(127);
}
close(report[1]);
int failure = 0;
ssize_t got = 0;
// Blocks until the child either execs (the write end closes, read returns 0) or
// reports why it could not.
while ((got = read(report[0], &failure, sizeof(failure))) < 0 && errno == EINTR) {
}
close(report[0]);
if (got == static_cast<ssize_t>(sizeof(failure))) {
*execErrno = failure;
}
*childPid = forked;
return 0;
}
} // namespace
SpawnSpikeResult RunSpawnSpike(const SpawnSpikeRequest& request) {
SpawnSpikeResult result;
result.parentSelinuxContext = ReadSelfSelinuxContext();
if (request.serverPath.empty() || request.markerPath.empty()) {
result.message = "spike-spawn: serverPath and markerPath are both required";
return result;
}
// A stale marker from a previous run would otherwise be read back as this run's
// proof. Remove it first, so "the marker exists" can only mean the child wrote it.
unlink(request.markerPath.c_str());
const std::string childOutputPath = request.markerPath + ".stdout";
unlink(childOutputPath.c_str());
struct stat serverStat {};
if (stat(request.serverPath.c_str(), &serverStat) != 0) {
result.spawnErrno = errno;
result.message = "spike-spawn: " + request.serverPath + " does not exist: " +
std::strerror(errno);
__android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s", result.message.c_str());
return result;
}
pid_t childPid = -1;
int execErrno = 0;
const int spawnStatus = SpawnSpikeChild(request.serverPath, request.markerPath,
childOutputPath, &childPid, &execErrno);
if (spawnStatus != 0) {
result.spawnErrno = spawnStatus;
result.message = "spike-spawn: could not start " + request.serverPath +
": spawnErrno=" + std::to_string(spawnStatus) + " (" +
std::strerror(spawnStatus) + ")";
__android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s (parentSelinux=%s)",
result.message.c_str(), result.parentSelinuxContext.c_str());
return result;
}
result.spawned = true;
result.childPid = static_cast<int>(childPid);
result.execErrno = execErrno;
int waitStatus = 0;
while (waitpid(childPid, &waitStatus, 0) < 0) {
if (errno != EINTR) {
result.message = "spike-spawn: waitpid failed: " + std::string(std::strerror(errno));
__android_log_print(ANDROID_LOG_ERROR, kSpikeLogTag, "%s", result.message.c_str());
return result;
}
}
result.waitStatus = waitStatus;
if (WIFEXITED(waitStatus)) {
result.exitCode = WEXITSTATUS(waitStatus);
}
if (WIFSIGNALED(waitStatus)) {
result.termSignal = WTERMSIG(waitStatus);
}
result.markerContent = ReadWholeFile(request.markerPath);
result.childOutput = ReadWholeFile(childOutputPath);
result.succeeded =
result.execErrno == 0 && result.exitCode == 0 && !result.markerContent.empty();
std::ostringstream message;
message << "spike-spawn: " << (result.succeeded ? "OK" : "FAILED")
<< " server=" << request.serverPath
<< " pid=" << result.childPid
<< " exit=" << result.exitCode
<< " signal=" << result.termSignal
// Always printed, including on the success path, so a reader never has to
// guess whether the field was collected or merely absent.
<< " execErrno=" << result.execErrno
<< " (" << (result.execErrno == 0 ? "exec succeeded"
: std::strerror(result.execErrno)) << ")"
<< " parentSelinux=" << result.parentSelinuxContext
<< " marker=[" << result.markerContent << "]"
<< " childStdout=[" << result.childOutput << "]";
result.message = message.str();
__android_log_print(result.succeeded ? ANDROID_LOG_INFO : ANDROID_LOG_ERROR, kSpikeLogTag,
"%s", result.message.c_str());
// The Activity is normally gone as soon as the run finishes, so the verdict also goes
// to a file next to the marker; that is what a device lane copies out.
std::ofstream report(request.markerPath + ".report", std::ios::trunc);
if (report) {
report << result.message << "\n";
}
return result;
}
} // namespace mobilegl_trace
@@ -0,0 +1,60 @@
#pragma once
// ---------------------------------------------------------------------------
// P0 spike A: exec a second packaged native executable from this process.
//
// Answers one question and nothing else: can an ordinary Android application
// process (untrusted_app, NOT an `adb run-as` shell, which runs in a different
// SELinux domain and would prove nothing) exec a binary that was shipped inside
// its own APK as lib/<abi>/lib*.so? The disaggregated design needs a server
// process on Android and this is its only supported delivery route (PLAN-B.md
// §8.1, inheriting PLAN.md §11.1-§11.6).
//
// This lives beside trace_replay_core.hpp rather than inside it because
// trace_replay_core.cpp is ALSO compiled by the desktop mobilegl_trace_replay
// runner (tools/trace_replay/CMakeLists.txt names it directly), where <android/log.h>
// does not exist. The spike is Android-only, so it gets an Android-only TU;
// spawn_spike.cpp is listed only by the trace APK's CMakeLists.
//
// Nothing in the replay path calls this; it runs only when the trace Activity is
// launched with the `mobilegl_spike_spawn` intent extra.
// ---------------------------------------------------------------------------
#include <string>
namespace mobilegl_trace {
struct SpawnSpikeRequest {
// Absolute path of the executable, normally
// getApplicationInfo().nativeLibraryDir + "/libMobileGLServer.so".
std::string serverPath;
// Marker file the child is asked to write, passed to it as argv[1]. The child's
// stdout and stderr are captured next to it, with ".stdout" appended.
std::string markerPath;
};
struct SpawnSpikeResult {
bool spawned = false;
// Exec'd, waited for, exited 0, and the marker file came back non-empty.
bool succeeded = false;
// errno of the pre-fork or fork failure - the parent could not even try.
int spawnErrno = 0;
// errno of a REFUSED execve, carried out of the child over a close-on-exec pipe.
// This is the one datum the spike exists to produce: EACCES (SELinux or the mount's
// noexec) and ENOEXEC (the packager mangled the file) are different verdicts, and
// the exit status alone cannot tell them apart.
int execErrno = 0;
int childPid = -1;
int waitStatus = -1;
int exitCode = -1;
int termSignal = -1;
// /proc/self/attr/current of THIS process - the domain the exec was attempted from.
std::string parentSelinuxContext;
std::string markerContent;
std::string childOutput;
std::string message;
};
SpawnSpikeResult RunSpawnSpike(const SpawnSpikeRequest& request);
} // namespace mobilegl_trace
@@ -0,0 +1,74 @@
#pragma once
// The generic environment passthrough of the retrace lane, split out of
// trace_replay_jni.cpp and trace_replay_core.cpp so a host-side test can pin it.
//
// One intent extra (`--es mobilegl_env "K=V;K=V"`) carries every MOBILEGL_* knob that has
// no dedicated flag, which is what PLAN-B.md §11 P0 needs when it adds MOBILEGL_PIPE_*.
// That makes this hand-rolled two-level parse the single point where the whole batch can
// be silently misread, and the only lane that exercises it end to end runs on a device -
// hence tools/trace_replay/trace_env_overrides_test.cpp, which every desktop configure
// that builds the replay runner runs at build time.
#include <cstddef>
#include <string>
#include <vector>
namespace mobilegl_trace {
// Splits `A;B;C` into its entries, dropping empty ones. Also used for the texture and
// FBO dump lists, whose entries carry their own ',' and ':' separators. A value that
// itself contains ';' therefore cannot be expressed - that is the format's limit, not a
// bug to work around here.
inline std::vector<std::string> SplitSemicolonList(const std::string& value) {
std::vector<std::string> values;
std::size_t begin = 0;
while (begin < value.size()) {
const std::size_t end = value.find(';', begin);
const std::string entry = value.substr(begin, end - begin);
if (!entry.empty()) {
values.push_back(entry);
}
if (end == std::string::npos) {
break;
}
begin = end + 1;
}
return values;
}
enum class EnvOverrideAction {
// Nothing to do: the entry is empty, or names an empty key.
Ignore,
// setenv(key, value, 1). `K=` is a Set of the empty string, deliberately distinct
// from Unset: a knob read with getenv() != nullptr treats them differently.
Set,
// unsetenv(key). An entry with no '=' means this, and it is the only way for a
// caller to clear a variable the per-knob marshalling above it already set.
Unset,
};
// Classifies one `KEY=VALUE` / `KEY` entry. The first '=' separates; later ones belong to
// the value, so `KEY=a=b` sets KEY to `a=b`.
inline EnvOverrideAction ParseEnvOverride(const std::string& entry,
std::string* key,
std::string* value) {
key->clear();
value->clear();
const std::size_t separator = entry.find('=');
if (separator == std::string::npos) {
if (entry.empty()) {
return EnvOverrideAction::Ignore;
}
*key = entry;
return EnvOverrideAction::Unset;
}
if (separator == 0) {
return EnvOverrideAction::Ignore;
}
*key = entry.substr(0, separator);
*value = entry.substr(separator + 1);
return EnvOverrideAction::Set;
}
} // namespace mobilegl_trace
@@ -4,6 +4,7 @@
#include "apitrace_exit.hpp"
#include "png.h"
#include "trace_benchmark.hpp"
#include "trace_env_overrides.hpp"
#include <algorithm>
#include <cerrno>
@@ -130,6 +131,32 @@ std::string JsonEscape(const std::string& value) {
return out.str();
}
// Generic environment passthrough. One intent extra carries `K=V;K=V`, so a new
// MOBILEGL_* knob costs nothing in the five files between the CI script and this
// setenv - the per-knob plumbing above is what this replaces going forward
// (PLAN-B.md §11 P0, which adds a batch of MOBILEGL_PIPE_* switches).
//
// Applied last, immediately before the library is loaded: it is the escape hatch, so it
// has to be able to override the fields marshalled above, and MobileGL's ConfigLoader
// reads the environment during dlopen. The decision of what each entry means lives in
// trace_env_overrides.hpp so a host-side test can pin it; this is only the setenv.
void ApplyEnvOverrides(const std::vector<std::string>& entries) {
for (const std::string& entry : entries) {
std::string key;
std::string value;
switch (ParseEnvOverride(entry, &key, &value)) {
case EnvOverrideAction::Set:
setenv(key.c_str(), value.c_str(), 1);
break;
case EnvOverrideAction::Unset:
unsetenv(key.c_str());
break;
case EnvOverrideAction::Ignore:
break;
}
}
}
bool LoadMobileGL(const Request& request, std::string& error) {
setenv("MOBILEGL_BACKEND_TYPE", request.backend.c_str(), 1);
setenv("MOBILEGL_TRACE_LIBRARY", request.mobileGlLibrary.c_str(), 1);
@@ -207,6 +234,8 @@ bool LoadMobileGL(const Request& request, std::string& error) {
setenv("MOBILEGL_TRACE_DUMP_TEXTURE_2D", dumpPoints.c_str(), 1);
}
ApplyEnvOverrides(request.envOverrides);
void* handle = dlopen(request.mobileGlLibrary.c_str(), RTLD_NOW | RTLD_GLOBAL);
if (handle == nullptr) {
const char* dlError = dlerror();
@@ -63,6 +63,11 @@ struct Request {
bool deriveNumSubgroups = false;
bool iterationRPFixBarrier = false;
int holdMs = 0;
// Generic environment passthrough, each entry `KEY=VALUE` (an entry with no '='
// unsets KEY). Applied last, right before libMobileGL.so is loaded, so a knob that
// has no dedicated field above can still be forwarded from the CI script without
// touching this struct again.
std::vector<std::string> envOverrides;
};
struct Result {
@@ -1,5 +1,8 @@
#include "trace_replay_core.hpp"
#include "spawn_spike.hpp"
#include "trace_env_overrides.hpp"
#include <android/native_window.h>
#include <android/native_window_jni.h>
#include <jni.h>
@@ -26,22 +29,7 @@ std::string ToString(JNIEnv* env, jstring value) {
return out;
}
std::vector<std::string> SplitSemicolonList(const std::string& value) {
std::vector<std::string> values;
std::size_t begin = 0;
while (begin < value.size()) {
const std::size_t end = value.find(';', begin);
const std::string entry = value.substr(begin, end - begin);
if (!entry.empty()) {
values.push_back(entry);
}
if (end == std::string::npos) {
break;
}
begin = end + 1;
}
return values;
}
using mobilegl_trace::SplitSemicolonList;
jobject MakeResult(JNIEnv* env, const mobilegl_trace::Result& result) {
jclass clazz = env->FindClass("top/mobilegl/plugin/trace/TraceReplayActivity$TraceReplayResult");
@@ -129,7 +117,8 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
jboolean benchmarkMode,
jint benchmarkTailFrames,
jboolean benchmarkFinish,
jstring benchmarkResultPath) {
jstring benchmarkResultPath,
jstring envOverrides) {
mobilegl_trace::Request request;
request.tracePath = ToString(env, tracePath);
request.goldenPath = ToString(env, goldenPath);
@@ -166,6 +155,7 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
: mobilegl_trace::kDefaultBenchmarkTailFrames;
request.benchmarkFinish = benchmarkFinish == JNI_TRUE;
request.benchmarkResultPath = ToString(env, benchmarkResultPath);
request.envOverrides = SplitSemicolonList(ToString(env, envOverrides));
ScopedTraceReplayState replayState;
mobilegl_trace_set_requested_size(request.width, request.height);
@@ -196,3 +186,20 @@ Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunTraceReplay(JNIEnv*
}
return MakeResult(env, result);
}
// P0 spike A: exec the packaged MobileGLServer stub from this app process and report what
// happened. Deliberately a separate entry point rather than another parameter on the
// replay call - it shares nothing with a replay, and the trace lane must be able to run
// it without a trace, a golden or a surface.
extern "C" JNIEXPORT jstring JNICALL
Java_top_mobilegl_plugin_trace_TraceReplayActivity_nativeRunSpawnSpike(JNIEnv* env,
jclass,
jstring serverPath,
jstring markerPath) {
mobilegl_trace::SpawnSpikeRequest request;
request.serverPath = ToString(env, serverPath);
request.markerPath = ToString(env, markerPath);
const mobilegl_trace::SpawnSpikeResult result = mobilegl_trace::RunSpawnSpike(request);
return env->NewStringUTF(result.message.c_str());
}
@@ -54,6 +54,14 @@ public final class TraceReplayActivity extends Activity {
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
));
// P0 spike A: when asked, exec the packaged server stub out of nativeLibraryDir
// instead of replaying anything. This mode needs no trace and no render surface.
String spikeLibrary = spawnSpikeLibrary(intent);
if (spikeLibrary != null) {
runSpawnSpike(spikeLibrary);
return;
}
SurfaceHolder holder = surfaceView.getHolder();
if (request.width > 0 && request.height > 0) {
holder.setFixedSize(request.width, request.height);
@@ -123,7 +131,8 @@ public final class TraceReplayActivity extends Activity {
request.benchmark,
request.benchmarkTailFrames,
request.benchmarkFinish,
request.benchmarkResultPath
request.benchmarkResultPath,
request.envOverrides
);
Log.i(TAG, result.toString());
TraceReplayResult finalResult = result;
@@ -163,9 +172,57 @@ public final class TraceReplayActivity extends Activity {
boolean benchmark,
int benchmarkTailFrames,
boolean benchmarkFinish,
String benchmarkResultPath
String benchmarkResultPath,
String envOverrides
);
// ---------------------------------------------------------------------------
// P0 spike A: prove an APK can ship a second native executable and exec it.
//
// The exec has to happen here, in the application's own process: an `adb shell
// run-as` invocation runs in a different SELinux domain, so it can succeed while
// the real app is denied. The child reports the domain it ended up in, and the
// parent reports the domain it spawned from, so the log line stands on its own.
// ---------------------------------------------------------------------------
private static final String EXTRA_SPAWN_SPIKE = "mobilegl_spike_spawn";
private static final String DEFAULT_SPAWN_SPIKE_LIBRARY = "libMobileGLServer.so";
private static String spawnSpikeLibrary(Intent intent) {
if (!intent.hasExtra(EXTRA_SPAWN_SPIKE)) {
return null;
}
// Accepts --ez (boolean, arrives as a null string) and --es with either a truthy
// marker or the library file name to exec.
String value = intent.getStringExtra(EXTRA_SPAWN_SPIKE);
if (value == null || value.isEmpty() || "1".equals(value) || "true".equals(value)) {
return DEFAULT_SPAWN_SPIKE_LIBRARY;
}
return value;
}
private void runSpawnSpike(String libraryName) {
// The surface callbacks fire regardless; this keeps them from starting a replay
// underneath the spike.
started = true;
File outputDir = new File(request.outputDir);
String serverPath = new File(getApplicationInfo().nativeLibraryDir, libraryName)
.getAbsolutePath();
String markerPath = new File(outputDir, "spike-spawn.txt").getAbsolutePath();
statusView.setText("Running spawn spike\n" + serverPath);
new Thread(() -> {
outputDir.mkdirs();
String message = nativeRunSpawnSpike(serverPath, markerPath);
Log.i(TAG, message);
runOnUiThread(() -> {
statusView.setText(message);
finish();
});
}, "MobileGLSpawnSpike").start();
}
private static native String nativeRunSpawnSpike(String serverPath, String markerPath);
private static final class TraceReplayRequest {
final String tracePath;
final String goldenPath;
@@ -199,6 +256,9 @@ public final class TraceReplayActivity extends Activity {
final int benchmarkTailFrames;
final boolean benchmarkFinish;
final String benchmarkResultPath;
// Generic environment passthrough, `K=V;K=V`. A future MOBILEGL_* knob needs no
// new intent extra, no new JNI parameter and no new field beside this one.
final String envOverrides;
private TraceReplayRequest(
String tracePath,
@@ -229,7 +289,8 @@ public final class TraceReplayActivity extends Activity {
boolean benchmark,
int benchmarkTailFrames,
boolean benchmarkFinish,
String benchmarkResultPath
String benchmarkResultPath,
String envOverrides
) {
this.tracePath = tracePath;
this.goldenPath = goldenPath;
@@ -260,6 +321,7 @@ public final class TraceReplayActivity extends Activity {
this.benchmarkTailFrames = benchmarkTailFrames;
this.benchmarkFinish = benchmarkFinish;
this.benchmarkResultPath = benchmarkResultPath;
this.envOverrides = envOverrides;
}
static TraceReplayRequest from(Intent intent, File filesDir, String defaultBackend) {
@@ -296,7 +358,8 @@ public final class TraceReplayActivity extends Activity {
intent.getBooleanExtra("benchmark", false),
intent.getIntExtra("benchmark_tail_frames", 200),
intent.getBooleanExtra("benchmark_finish", true),
benchmarkResultPath
benchmarkResultPath,
readString(intent, "mobilegl_env", "")
);
}
+14
View File
@@ -32,6 +32,7 @@ Usage:
[--avoid-angle-llvmpipe-explicit-lod-bias] \
[--coherent-as-flush] \
[--dump-texture-2d CALL,TEXTURE,LEVEL,DIR] \
[--env "K=V;K=V"] \
[--benchmark] \
[--benchmark-tail-frames N] \
[--benchmark-finish 0|1] \
@@ -60,6 +61,11 @@ copies benchmark.json (per-frame times plus mean/median/p95) out of the app, and
"passed" only means the replay reached the end of the trace without an error.
Pass --reuse-fixture to skip re-extracting and re-pushing the trace, for repeat
runs of a case whose fixture is already in /data/local/tmp.
Pass --env "K=V;K=V" (or set MOBILEGL_TRACE_ENV) to hand arbitrary environment
variables to the replay process. They are applied last, immediately before
libMobileGL.so is loaded, so they override every flag above; an entry with no "="
unsets the variable instead. This is the generic passthrough: a MOBILEGL_* knob
that has no flag of its own needs no plumbing to be forwarded.
EOF
}
@@ -119,6 +125,7 @@ avoid_angle_llvmpipe_sampler_mipmap_min_filter=0
avoid_angle_llvmpipe_explicit_lod_bias=0
coherent_as_flush=0
texture_2d_dumps=""
env_overrides="${MOBILEGL_TRACE_ENV:-}"
benchmark=0
benchmark_tail_frames=200
benchmark_finish=1
@@ -164,6 +171,7 @@ while [ "$#" -gt 0 ]; do
;;
--coherent-as-flush) coherent_as_flush=1; shift 1 ;;
--dump-texture-2d) texture_2d_dumps="$(next_arg "$@")"; shift 2 ;;
--env) env_overrides="$(next_arg "$@")"; shift 2 ;;
--benchmark) benchmark=1; shift 1 ;;
--benchmark-tail-frames) benchmark_tail_frames="$(next_arg "$@")"; shift 2 ;;
--benchmark-finish) benchmark_finish="$(next_arg "$@")"; shift 2 ;;
@@ -400,6 +408,12 @@ run_retrace() {
if [ -n "${texture_2d_dumps}" ]; then
set -- "$@" --es texture_2d_dumps "${texture_2d_dumps}"
fi
if [ -n "${env_overrides}" ]; then
# adb joins the argv with spaces and hands the result to the device shell, so a value
# holding the ';' that separates entries would otherwise be read there as a command
# separator. The single quotes make it one token again.
set -- "$@" --es mobilegl_env "'${env_overrides}'"
fi
if [ "${benchmark}" -eq 1 ]; then
set -- "$@" --ez benchmark true
set -- "$@" --ei benchmark_tail_frames "${benchmark_tail_frames}"

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